From 94fc80f8d7785b5ea9c14ced41b20854b10844f8 Mon Sep 17 00:00:00 2001 From: Christian Melchior Date: Wed, 10 Aug 2022 13:03:35 +0200 Subject: [PATCH] Support for Realm.writeCopyTo (#955) --- CHANGELOG.md | 27 +- .../kotlin/internal/interop/RealmInterop.kt | 1 + .../kotlin/internal/interop/RealmInterop.kt | 6 + .../kotlin/internal/interop/RealmInterop.kt | 4 + .../kotlin/io/realm/kotlin/Realm.kt | 24 ++ .../kotlin/internal/ConfigurationImpl.kt | 3 +- .../kotlin/internal/InternalConfiguration.kt | 3 + .../kotlin/internal/RealmConfigurationImpl.kt | 3 +- .../io/realm/kotlin/internal/RealmImpl.kt | 23 ++ .../mongodb/internal/SyncSessionImpl.kt | 8 +- .../kotlin/mongodb/sync/SyncConfiguration.kt | 3 +- .../kotlin/io/realm/kotlin/test/RealmTests.kt | 2 - .../io/realm/kotlin/test/shared/RealmTests.kt | 112 +++++++ .../kotlin/test/shared/SyncedRealmTests.kt | 297 +++++++++++++++++- .../kotlin/test/mongodb/util/AdminApi.kt | 2 +- 15 files changed, 506 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233153e294..668aca626f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,37 @@ -## 1.0.2 (2022-08-05) +## 1.1.0 (YYYY-MM-DD) ### Breaking Changes * None. ### Enhancements * Added support for `UUID` through a new property type: `RealmUUID`. +* Support for `Realm.writeCopyTo(configuration)`. * [Sync] Add support for `User.delete()`, making it possible to delete user data on the server side (Issue [#491](https://github.com/realm/realm-kotlin/issues/491)). +### Fixed +* `Realm.deleteRealm(config)` would throw an exception if the file didn't exist. + +### Compatibility +* This release is compatible with: + * Kotlin 1.6.10 and above. + * Coroutines 1.6.0-native-mt. Also compatible with Coroutines 1.6.0 but requires enabling of the new memory model and disabling of freezing, see https://github.com/realm/realm-kotlin#kotlin-memory-model-and-coroutine-compatibility for details on that. + * AtomicFu 0.17.0. +* Minimum Gradle version: 6.1.1. +* Minimum Android Gradle Plugin version: 4.0.0. +* Minimum Android SDK: 16. + +### Internal +* None. + + +## 1.0.2 (2022-08-05) + +### Breaking Changes +* None. + +### Enhancements +* None. + ### Fixed * Missing proguard configuration for `CoreErrorUtils`. (Issue [#942](https://github.com/realm/realm-kotlin/issues/942)) * [Sync] Embedded Objects could not be added to the schema for `SyncConfiguration`s. (Issue [#945](https://github.com/realm/realm-kotlin/issues/945)). diff --git a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 170f08de2b..8c08218bbb 100644 --- a/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/commonMain/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -159,6 +159,7 @@ expect object RealmInterop { fun realm_is_frozen(realm: RealmPointer): Boolean fun realm_close(realm: RealmPointer) fun realm_delete_files(path: String) + fun realm_convert_with_config(realm: RealmPointer, config: RealmConfigurationPointer) fun realm_get_schema(realm: RealmPointer): RealmSchemaPointer fun realm_get_schema_version(realm: RealmPointer): Long diff --git a/packages/cinterop/src/darwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/darwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 16786e9639..254abd4321 100644 --- a/packages/cinterop/src/darwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/darwin/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -577,6 +577,12 @@ actual object RealmInterop { } } + actual fun realm_convert_with_config(realm: RealmPointer, config: RealmConfigurationPointer) { + memScoped { + checkedBooleanResult(realm_wrapper.realm_convert_with_config(realm.cptr(), config.cptr())) + } + } + actual fun realm_get_schema(realm: RealmPointer): RealmSchemaPointer { return CPointerWrapper(realm_wrapper.realm_get_schema(realm.cptr())) } diff --git a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt index 7de346df81..529b6e77ea 100644 --- a/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt +++ b/packages/cinterop/src/jvm/kotlin/io/realm/kotlin/internal/interop/RealmInterop.kt @@ -221,6 +221,10 @@ actual object RealmInterop { } } + actual fun realm_convert_with_config(realm: RealmPointer, config: RealmConfigurationPointer) { + realmc.realm_convert_with_config(realm.cptr(), config.cptr()) + } + actual fun realm_schema_validate(schema: RealmSchemaPointer, mode: SchemaValidationMode): Boolean { return realmc.realm_schema_validate(schema.cptr(), mode.nativeValue.toLong()) } diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Realm.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Realm.kt index 065b8d9657..1583b4505c 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Realm.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/Realm.kt @@ -20,6 +20,7 @@ import io.realm.kotlin.internal.InternalConfiguration import io.realm.kotlin.internal.RealmImpl import io.realm.kotlin.internal.interop.Constants import io.realm.kotlin.internal.interop.RealmInterop +import io.realm.kotlin.internal.platform.fileExists import io.realm.kotlin.notifications.RealmChange import io.realm.kotlin.query.RealmQuery import io.realm.kotlin.types.BaseRealmObject @@ -96,6 +97,7 @@ public interface Realm : TypedRealm { * @throws IllegalStateException if an error occurred while deleting the Realm files. */ public fun deleteRealm(configuration: Configuration) { + if (!fileExists(configuration.path)) return try { RealmInterop.realm_delete_files(configuration.path) } catch (exception: Throwable) { @@ -171,6 +173,28 @@ public interface Realm : TypedRealm { */ public fun asFlow(): Flow> + /** + * Writes a compacted copy of the Realm to the given destination as defined by the + * [targetConfiguration]. The resulting file can be used for a number of purposes: + * + * - Backup of a local realm. + * - Backup of a synchronized realm, but all local changes must be uploaded first. + * - Convert a local realm to a partition-based realm. + * - Convert a synchronized (partition-based or flexible) realm to a local realm. + * + * Encryption can be configured for the target Realm independently from the current Realm. + * + * The destination file cannot already exist. + * + * @param targetConfiguration configuration that defines what type of backup to make and where + * to write it by using [Configuration.path]. + * @throws IllegalArgumentException if [targetConfiguration] points to a file that already exists. + * @throws IllegalArgumentException if [targetConfiguration] has Flexible Sync enabled. + * @throws IllegalStateException if this Realm is a synchronized Realm, and not all client + * changes are integrated in the server. + */ + public fun writeCopyTo(targetConfiguration: Configuration) + /** * Close this realm and all underlying resources. Accessing any methods or Realm Objects after * this method has been called will then an [IllegalStateException]. diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt index a4d5975a42..5aaa26c56b 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/ConfigurationImpl.kt @@ -60,7 +60,8 @@ public open class ConfigurationImpl constructor( private val userEncryptionKey: ByteArray?, compactOnLaunchCallback: CompactOnLaunchCallback?, private val userMigration: RealmMigration?, - initialDataCallback: InitialDataCallback? + initialDataCallback: InitialDataCallback?, + override val isFlexibleSyncConfiguration: Boolean ) : InternalConfiguration { override val path: String diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/InternalConfiguration.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/InternalConfiguration.kt index a27330b9c9..cc6182be6e 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/InternalConfiguration.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/InternalConfiguration.kt @@ -36,6 +36,9 @@ public interface InternalConfiguration : Configuration { public val writeDispatcher: CoroutineDispatcher public val schemaMode: SchemaMode + // Temporary work-around for https://github.com/realm/realm-kotlin/issues/724 + public val isFlexibleSyncConfiguration: Boolean + /** * Creates a new native Config object based on all the settings in this configuration. * Each pointer should only be used to open _one_ realm. If you want to open multiple realms diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt index 47727d0671..954978c5b4 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmConfigurationImpl.kt @@ -59,6 +59,7 @@ internal class RealmConfigurationImpl constructor( encryptionKey, compactOnLaunchCallback, migration, - initialDataCallback + initialDataCallback, + false ), RealmConfiguration diff --git a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt index 4884ea6b07..dc30c1c1a5 100644 --- a/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt +++ b/packages/library-base/src/commonMain/kotlin/io/realm/kotlin/internal/RealmImpl.kt @@ -16,12 +16,15 @@ package io.realm.kotlin.internal +import io.realm.kotlin.Configuration import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.dynamic.DynamicRealm +import io.realm.kotlin.exceptions.RealmException import io.realm.kotlin.internal.dynamic.DynamicRealmImpl import io.realm.kotlin.internal.interop.LiveRealmPointer import io.realm.kotlin.internal.interop.RealmInterop +import io.realm.kotlin.internal.platform.fileExists import io.realm.kotlin.internal.platform.runBlocking import io.realm.kotlin.internal.schema.RealmSchemaImpl import io.realm.kotlin.notifications.RealmChange @@ -194,6 +197,26 @@ public class RealmImpl private constructor( ).flattenConcat() } + override fun writeCopyTo(configuration: Configuration) { + if (fileExists(configuration.path)) { + throw IllegalArgumentException("File already exists at: ${configuration.path}. Realm can only write a copy to an empty path.") + } + val internalConfig = (configuration as InternalConfiguration) + if (internalConfig.isFlexibleSyncConfiguration) { + throw IllegalArgumentException("Creating a copy of a Realm where the target has Flexible Sync enabled is currently not supported.") + } + val configPtr = internalConfig.createNativeConfiguration() + try { + RealmInterop.realm_convert_with_config(realmReference.dbPointer, configPtr) + } catch (ex: RealmException) { + if (ex.message?.contains("Could not write file as not all client changes are integrated in server") == true) { + throw IllegalStateException(ex.message) + } else { + throw ex + } + } + } + override fun registerObserver(t: Thawable>): Flow { return notifier.registerObserver(t) } diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncSessionImpl.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncSessionImpl.kt index 8c7ada769e..634959877f 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncSessionImpl.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/internal/SyncSessionImpl.kt @@ -148,9 +148,11 @@ internal open class SyncSessionImpl( channel.receive() } } - if (direction == TransferDirection.DOWNLOAD) { - realm.refresh() - } + // We need to refresh the public Realm when downloading to make the changes visible + // to users immediately. + // We need to refresh the public Realm when uploading in order to support functionality + // like `Realm.writeCopyTo()` which require that all changes are uploaded. + realm.refresh() when (result) { is Boolean -> return result is Throwable -> throw result diff --git a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt index 7aafa5330d..cbf5824f87 100644 --- a/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt +++ b/packages/library-sync/src/commonMain/kotlin/io/realm/kotlin/mongodb/sync/SyncConfiguration.kt @@ -550,7 +550,8 @@ public interface SyncConfiguration : Configuration { encryptionKey, compactOnLaunchCallback, null, // migration is not relevant for sync, - initialDataCallback + initialDataCallback, + partitionValue == null ) return SyncConfigurationImpl( diff --git a/test/base/src/androidTest/kotlin/io/realm/kotlin/test/RealmTests.kt b/test/base/src/androidTest/kotlin/io/realm/kotlin/test/RealmTests.kt index 28f52ea9c7..79716bd78e 100644 --- a/test/base/src/androidTest/kotlin/io/realm/kotlin/test/RealmTests.kt +++ b/test/base/src/androidTest/kotlin/io/realm/kotlin/test/RealmTests.kt @@ -26,9 +26,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.time.ExperimentalTime -@OptIn(ExperimentalTime::class) class RealmTests { private lateinit var tmpDir: String diff --git a/test/base/src/androidTest/kotlin/io/realm/kotlin/test/shared/RealmTests.kt b/test/base/src/androidTest/kotlin/io/realm/kotlin/test/shared/RealmTests.kt index c884040a42..069c4ef66f 100644 --- a/test/base/src/androidTest/kotlin/io/realm/kotlin/test/shared/RealmTests.kt +++ b/test/base/src/androidTest/kotlin/io/realm/kotlin/test/shared/RealmTests.kt @@ -15,6 +15,7 @@ */ package io.realm.kotlin.test.shared +import io.realm.kotlin.Configuration import io.realm.kotlin.Realm import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.VersionId @@ -26,6 +27,8 @@ import io.realm.kotlin.ext.version import io.realm.kotlin.query.find import io.realm.kotlin.test.assertFailsWithMessage import io.realm.kotlin.test.platform.PlatformUtils +import io.realm.kotlin.test.platform.platformFileSystem +import io.realm.kotlin.test.util.use import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin @@ -37,6 +40,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import okio.FileSystem import okio.Path.Companion.toPath +import kotlin.random.Random +import kotlin.random.Random.Default.nextBytes import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Ignore @@ -468,6 +473,18 @@ class RealmTests { } } + @Test + fun deleteRealm_fileDoesNotExists() { + val fileSystem = FileSystem.SYSTEM + val testDir = PlatformUtils.createTempDir("test_dir") + val configuration = RealmConfiguration.Builder(schema = setOf(Parent::class, Child::class)) + .directory(testDir) + .build() + assertFalse(fileSystem.exists(configuration.path.toPath())) + Realm.deleteRealm(configuration) // No-op if file doesn't exists + assertFalse(fileSystem.exists(configuration.path.toPath())) + } + @Test fun deleteRealm_failures() { val tempDirA = PlatformUtils.createTempDir() @@ -494,6 +511,101 @@ class RealmTests { } } + private fun createWriteCopyLocalConfig(name: String, encryptionKey: ByteArray? = null): RealmConfiguration { + val builder = RealmConfiguration.Builder(schema = setOf(Parent::class, Child::class)) + .directory(tmpDir) + .name(name) + if (encryptionKey != null) { + builder.encryptionKey(encryptionKey) + } + return builder.build() + } + + private fun writeCopy(from: Configuration, to: Configuration) { + Realm.open(from).use { realm -> + realm.writeBlocking { + repeat(1000) { i: Int -> + copyToRealm( + Parent().apply { + name = "Object-$i" + } + ) + } + } + realm.writeCopyTo(to) + } + } + + @Test + fun writeCopyTo_localToLocal() { + val configA: RealmConfiguration = createWriteCopyLocalConfig("fileA.realm") + val configB: RealmConfiguration = createWriteCopyLocalConfig("fileB.realm") + + writeCopy(from = configA, to = configB) + + // Copy is compacted i.e. smaller than original. + val fileASize: Long = platformFileSystem.metadata(configA.path.toPath()).size!! + val fileBSize: Long = platformFileSystem.metadata(configB.path.toPath()).size!! + assertTrue(fileASize >= fileBSize, "$fileASize >= $fileBSize") + // Content is copied + Realm.open(configB).use { realm -> + assertEquals(1000, realm.query().count().find()) + } + } + + @Test + fun writeCopyTo_localToLocalEncrypted() { + val configA: RealmConfiguration = createWriteCopyLocalConfig("fileA.realm") + val configBEncrypted: RealmConfiguration = createWriteCopyLocalConfig("fileB.realm", Random.Default.nextBytes(Realm.ENCRYPTION_KEY_LENGTH)) + + writeCopy(from = configA, to = configBEncrypted) + + // Ensure that new Realm is encrypted + val configBUnencrypted: RealmConfiguration = RealmConfiguration.Builder(schema = setOf(Parent::class, Child::class)) + .directory(tmpDir) + .name("fileB.realm") + .build() + + assertFailsWith { + Realm.open(configBUnencrypted) + } + + Realm.open(configBEncrypted).use { realm -> + assertEquals(1000, realm.query().count().find()) + } + } + + @Test + fun writeCopyTo_localEncryptedToLocal() { + val key = Random.Default.nextBytes(Realm.ENCRYPTION_KEY_LENGTH) + val configAEncrypted: RealmConfiguration = createWriteCopyLocalConfig("fileA.realm", key) + val configB: RealmConfiguration = createWriteCopyLocalConfig("fileB.realm") + + writeCopy(from = configAEncrypted, to = configB) + + // Ensure that new Realm is not encrypted + val configBEncrypted: RealmConfiguration = createWriteCopyLocalConfig("fileB.realm", key) + assertFailsWith { + Realm.open(configBEncrypted) + } + + Realm.open(configB).use { realm -> + assertEquals(1000, realm.query().count().find()) + } + } + + @Test + fun writeCopyTo_destinationAlreadyExist_throws() { + val configA: RealmConfiguration = createWriteCopyLocalConfig("fileA.realm") + val configB: RealmConfiguration = createWriteCopyLocalConfig("fileB.realm") + Realm.open(configB).use {} + Realm.open(configA).use { realm -> + assertFailsWith { + realm.writeCopyTo(configB) + } + } + } + // TODO Cannot verify intermediate versions as they are now spread across user facing, notifier // and writer realms. Tests were anyway ignored, so don't really know what to do with these. // @Test diff --git a/test/sync/src/androidTest/kotlin/io/realm/kotlin/test/shared/SyncedRealmTests.kt b/test/sync/src/androidTest/kotlin/io/realm/kotlin/test/shared/SyncedRealmTests.kt index 8638d53e42..8d75d14486 100644 --- a/test/sync/src/androidTest/kotlin/io/realm/kotlin/test/shared/SyncedRealmTests.kt +++ b/test/sync/src/androidTest/kotlin/io/realm/kotlin/test/shared/SyncedRealmTests.kt @@ -18,10 +18,14 @@ package io.realm.kotlin.test.shared import io.realm.kotlin.LogConfiguration import io.realm.kotlin.Realm +import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.VersionId import io.realm.kotlin.entities.sync.ChildPk import io.realm.kotlin.entities.sync.ParentPk import io.realm.kotlin.entities.sync.SyncObjectWithAllTypes +import io.realm.kotlin.entities.sync.flx.FlexChildObject +import io.realm.kotlin.entities.sync.flx.FlexEmbeddedObject +import io.realm.kotlin.entities.sync.flx.FlexParentObject import io.realm.kotlin.ext.query import io.realm.kotlin.internal.platform.fileExists import io.realm.kotlin.internal.platform.freeze @@ -41,13 +45,15 @@ import io.realm.kotlin.test.mongodb.asTestApp import io.realm.kotlin.test.mongodb.createUserAndLogIn import io.realm.kotlin.test.mongodb.shared.DEFAULT_NAME import io.realm.kotlin.test.mongodb.util.SyncPermissions +import io.realm.kotlin.test.platform.PlatformUtils import io.realm.kotlin.test.util.TestHelper import io.realm.kotlin.test.util.TestHelper.randomEmail import io.realm.kotlin.test.util.use -import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.BaseRealmObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -71,7 +77,9 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +@Suppress("LargeClass") class SyncedRealmTests { companion object { @@ -562,6 +570,274 @@ class SyncedRealmTests { } } + private fun createWriteCopyLocalConfig(name: String, encryptionKey: ByteArray? = null): RealmConfiguration { + val builder = RealmConfiguration.Builder( + schema = setOf( + SyncObjectWithAllTypes::class, + FlexParentObject::class, + FlexChildObject::class, + FlexEmbeddedObject::class + ) + ) + .directory(PlatformUtils.createTempDir()) + .name(name) + if (encryptionKey != null) { + builder.encryptionKey(encryptionKey) + } + return builder.build() + } + + @Test + fun writeCopyTo_localToPartitionBasedSync() = runBlocking { + val (email1, password1) = randomEmail() to "password1234" + val (email2, password2) = randomEmail() to "password1234" + val user1 = app.createUserAndLogIn(email1, password1) + val user2 = app.createUserAndLogIn(email2, password2) + val localConfig = createWriteCopyLocalConfig("local.realm") + val partitionValue = TestHelper.randomPartitionValue() + val syncConfig1 = createSyncConfig( + user = user1, + name = "sync1.realm", + partitionValue = partitionValue, + schema = setOf(SyncObjectWithAllTypes::class) + ) + val syncConfig2 = createSyncConfig( + user = user2, + name = "sync2.realm", + partitionValue = partitionValue, + schema = setOf(SyncObjectWithAllTypes::class) + ) + Realm.open(localConfig).use { localRealm -> + localRealm.writeBlocking { + copyToRealm( + SyncObjectWithAllTypes().apply { + stringField = "local object" + } + ) + } + // Copy to partition-based Realm + localRealm.writeCopyTo(syncConfig1) + } + // Open Sync Realm and ensure that data can be used and uploaded + Realm.open(syncConfig1).use { syncRealm1: Realm -> + assertEquals(1, syncRealm1.query().count().find()) + assertEquals("local object", syncRealm1.query().first().find()!!.stringField) + syncRealm1.writeBlocking { + query().first().find()!!.apply { + stringField = "updated local object" + } + } + } + // Check that uploaded data can be used + Realm.open(syncConfig2).use { syncRealm2: Realm -> + val obj = syncRealm2.query().asFlow() + .first { it.list.size == 1 } + .list + .first() + assertEquals("updated local object", obj.stringField) + } + } + + @Test + fun writeCopyTo_localToFlexibleSync_throws() = runBlocking { + val flexApp = TestApp(appName = io.realm.kotlin.test.mongodb.TEST_APP_FLEX) + val (email1, password1) = randomEmail() to "password1234" + val user1 = flexApp.createUserAndLogIn(email1, password1) + val localConfig = createWriteCopyLocalConfig("local.realm") + val flexSyncConfig = createFlexibleSyncConfig( + user = user1, + schema = setOf(FlexParentObject::class, FlexChildObject::class, FlexEmbeddedObject::class) + ) + Realm.open(localConfig).use { localRealm -> + localRealm.writeBlocking { + copyToRealm( + SyncObjectWithAllTypes().apply { + stringField = "local object" + } + ) + } + assertFailsWith { + localRealm.writeCopyTo(flexSyncConfig) + } + } + } + + @Test + fun writeCopyTo_partitionBasedToLocal() = runBlocking { + val (email, password) = randomEmail() to "password1234" + val user = app.createUserAndLogIn(email, password) + val localConfig = createWriteCopyLocalConfig("local.realm") + val partitionValue = TestHelper.randomPartitionValue() + val syncConfig = createSyncConfig( + user = user, + name = "sync1.realm", + partitionValue = partitionValue, + schema = setOf(SyncObjectWithAllTypes::class) + ) + Realm.open(syncConfig).use { syncRealm -> + // Write local data + syncRealm.writeBlocking { + copyToRealm( + SyncObjectWithAllTypes().apply { + stringField = "local object" + } + ) + } + // Copy to partition-based Realm + syncRealm.writeCopyTo(localConfig) + } + // Open Local Realm and check that data can read. + Realm.open(localConfig).use { localRealm: Realm -> + assertEquals(1, localRealm.query().count().find()) + assertEquals("local object", localRealm.query().first().find()!!.stringField) + } + } + + @Test + fun writeCopyTo_flexibleSyncToLocal() = runBlocking { + val flexApp = TestApp(appName = io.realm.kotlin.test.mongodb.TEST_APP_FLEX) + val (email1, password1) = randomEmail() to "password1234" + val user = flexApp.createUserAndLogIn(email1, password1) + val localConfig = createWriteCopyLocalConfig("local.realm") + val syncConfig = createSyncConfig( + user = user, + name = "sync.realm", + partitionValue = partitionValue, + schema = setOf(FlexParentObject::class, FlexChildObject::class, FlexEmbeddedObject::class) + ) + Realm.open(syncConfig).use { flexSyncRealm: Realm -> + flexSyncRealm.writeBlocking { + copyToRealm( + FlexParentObject().apply { + name = "local object" + } + ) + } + // Copy to local Realm + flexSyncRealm.writeCopyTo(localConfig) + } + // Open Local Realm and check that data can read. + Realm.open(localConfig).use { localRealm: Realm -> + assertEquals(1, localRealm.query().count().find()) + assertEquals("local object", localRealm.query().first().find()!!.name) + } + } + + @Test + fun writeCopyTo_partitionBasedToDifferentPartitionKey() = runBlocking { + val (email1, password1) = randomEmail() to "password1234" + val (email2, password2) = randomEmail() to "password1234" + val user1 = app.createUserAndLogIn(email1, password1) + val user2 = app.createUserAndLogIn(email2, password2) + val syncConfig1 = createSyncConfig( + user = user1, + name = "sync1.realm", + partitionValue = TestHelper.randomPartitionValue(), + schema = setOf(SyncObjectWithAllTypes::class) + ) + val syncConfig2 = createSyncConfig( + user = user2, + name = "sync2.realm", + partitionValue = TestHelper.randomPartitionValue(), + schema = setOf(SyncObjectWithAllTypes::class) + ) + Realm.open(syncConfig1).use { syncRealm1 -> + syncRealm1.writeBlocking { + copyToRealm( + SyncObjectWithAllTypes().apply { + stringField = "local object" + } + ) + } + // Copy to partition-based Realm + syncRealm1.syncSession.uploadAllLocalChanges(30.seconds) + // Work-around for https://github.com/realm/realm-core/issues/4865 + // Calling syncRealm1.syncSession.downloadAllServerChanges doesn't seem to + // fix it in all cases + delay(1000) + syncRealm1.writeCopyTo(syncConfig2) + } + // Open Sync Realm and ensure that data can be used and uploaded + Realm.open(syncConfig2).use { syncRealm2: Realm -> + val result = syncRealm2.query().find() + assertEquals(1, result.size) + assertEquals("local object", result.first().stringField) + syncRealm2.syncSession.uploadAllLocalChanges(30.seconds) + } + } + + @Test + fun writeCopyTo_partitionBasedToSamePartitionKey() = runBlocking { + val (email1, password1) = randomEmail() to "password1234" + val (email2, password2) = randomEmail() to "password1234" + val user1 = app.createUserAndLogIn(email1, password1) + val user2 = app.createUserAndLogIn(email2, password2) + val partitionValue = TestHelper.randomPartitionValue() + val syncConfig1 = createSyncConfig( + user = user1, + name = "sync1.realm", + partitionValue = partitionValue, + schema = setOf(SyncObjectWithAllTypes::class) + ) + val syncConfig2 = createSyncConfig( + user = user2, + name = "sync2.realm", + partitionValue = partitionValue, + schema = setOf(SyncObjectWithAllTypes::class) + ) + Realm.open(syncConfig1).use { syncRealm1 -> + // Write local data + syncRealm1.writeBlocking { + copyToRealm( + SyncObjectWithAllTypes().apply { + stringField = "local object" + } + ) + } + // Copy to partition-based Realm + syncRealm1.syncSession.uploadAllLocalChanges(30.seconds) + // Work-around for https://github.com/realm/realm-core/issues/4865 + // Calling syncRealm1.syncSession.downloadAllServerChanges doesn't seem to + // fix it in all cases + delay(1000) + syncRealm1.writeCopyTo(syncConfig2) + } + // Open Sync Realm and ensure that data can be used and uploaded + Realm.open(syncConfig2).use { syncRealm2: Realm -> + val result = syncRealm2.query().find() + assertEquals(1, result.size) + assertEquals("local object", result.first().stringField) + syncRealm2.syncSession.uploadAllLocalChanges(30.seconds) + } + } + + @Test + fun writeCopyTo_dataNotUploaded_throws() = runBlocking { + val (email1, password1) = randomEmail() to "password1234" + val user1 = app.createUserAndLogIn(email1, password1) + val syncConfigA = createSyncConfig( + user = user1, + name = "a.realm", + partitionValue = TestHelper.randomPartitionValue(), + schema = setOf(SyncObjectWithAllTypes::class) + ) + val syncConfigB = createSyncConfig( + user = user1, + name = "b.realm", + partitionValue = TestHelper.randomPartitionValue(), + schema = setOf(SyncObjectWithAllTypes::class) + ) + Realm.open(syncConfigA).use { realm -> + realm.syncSession.pause() + realm.writeBlocking { + copyToRealm(SyncObjectWithAllTypes()) + } + assertFailsWith { + realm.writeCopyTo(syncConfigB) + } + } + } + // @Test // fun initialVersion() { // assertEquals(INITIAL_VERSION, realm.version()) @@ -911,7 +1187,7 @@ class SyncedRealmTests { encryptionKey: ByteArray? = null, log: LogConfiguration? = null, errorHandler: ErrorHandler? = null, - schema: Set> = setOf(ParentPk::class, ChildPk::class), + schema: Set> = setOf(ParentPk::class, ChildPk::class), ): SyncConfiguration = SyncConfiguration.Builder( schema = schema, user = user, @@ -921,4 +1197,21 @@ class SyncedRealmTests { if (errorHandler != null) builder.errorHandler(errorHandler) if (log != null) builder.log(log.level, log.loggers) }.build() + + @Suppress("LongParameterList") + private fun createFlexibleSyncConfig( + user: User, + name: String = DEFAULT_NAME, + encryptionKey: ByteArray? = null, + log: LogConfiguration? = null, + errorHandler: ErrorHandler? = null, + schema: Set> = setOf(SyncObjectWithAllTypes::class), + ): SyncConfiguration = SyncConfiguration.Builder( + user = user, + schema = schema + ).name(name).also { builder -> + if (encryptionKey != null) builder.encryptionKey(encryptionKey) + if (errorHandler != null) builder.errorHandler(errorHandler) + if (log != null) builder.log(log.level, log.loggers) + }.build() } diff --git a/test/sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AdminApi.kt b/test/sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AdminApi.kt index 99d4c94e9b..f4f0e715f3 100644 --- a/test/sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AdminApi.kt +++ b/test/sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AdminApi.kt @@ -199,7 +199,7 @@ open class AdminApiImpl internal constructor( // Get app id appId = client.typedRequest(Get, "$url/groups/$groupId/apps") - .firstOrNull { it.jsonObject["client_app_id"]?.jsonPrimitive?.content == appName }?.jsonObject?.get( + .firstOrNull { it.jsonObject["client_app_id"]?.jsonPrimitive?.content!!.startsWith(appName) }?.jsonObject?.get( "_id" )?.jsonPrimitive?.content ?: error("App $appName not found")