From 195b221739fc11c49d5c26a11af70e39ff8b11a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:31:15 +0200 Subject: [PATCH] [Automated] Merge releases into main (#1794) --- .github/workflows/pr.yml | 19 +- buildSrc/src/main/kotlin/Config.kt | 4 +- .../cinterop/src/jvmMain/generic.Dockerfile | 31 -- packages/test-sync/build.gradle.kts | 2 + .../io/realm/kotlin/test/mongodb/TestApp.kt | 26 -- .../kotlin/test/mongodb/util/AppAdmin.kt | 21 -- .../test/mongodb/util/AppServicesClient.kt | 288 ++++++++++++++++-- .../test/mongodb/util/SchemaProcessor.kt | 224 ++++++++++++++ .../test/mongodb/util/TestAppInitializer.kt | 111 ++----- .../mongodb/common/SchemaProcessorTests.kt | 70 +++++ 10 files changed, 594 insertions(+), 202 deletions(-) delete mode 100644 packages/cinterop/src/jvmMain/generic.Dockerfile create mode 100644 packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/SchemaProcessor.kt create mode 100644 packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SchemaProcessorTests.kt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 98249ff7c4..40494fde38 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -84,7 +84,6 @@ jobs: cd ~ curl -L ${{ vars.VERSION_SWIG}} > swig.rb && HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=true brew install swig.rb - - name: Build JNI Stub working-directory: ./packages run: ./gradlew :jni-swig-stub:assemble -Prealm.kotlin.buildRealmCore=false -Prealm.kotlin.mainHost=false @@ -97,7 +96,7 @@ jobs: retention-days: 1 build-jvm-linux-native-lib: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: [check-cache, build-jni-swig-stub] if: | always() && @@ -133,21 +132,9 @@ jobs: name: jni-stub-${{ needs.check-cache.outputs.version-label }} path: ./packages/jni-swig-stub/build/generated/sources/jni - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - tags: jvm-native-lib-linux:latest - file: ./packages/cinterop/src/jvmMain/generic.Dockerfile - push: false - - name: Build native lib - uses: addnab/docker-run-action@v3 - with: - image: jvm-native-lib-linux:latest - shell: bash - options: -v ${{ github.workspace }}:/work - run: | - cd /work/packages/cinterop + working-directory: ./packages/cinterop + run: | mkdir build cd build rm -rf realmLinuxBuild diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 2e5864113d..749866c7c2 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -128,12 +128,12 @@ object Versions { const val latestKotlin = "2.0.0" // https://kotlinlang.org/docs/eap.html#build-details const val kotlinCompileTesting = "0.5.0-alpha07" // https://github.com/zacsweers/kotlin-compile-testing const val ktlint = "0.45.2" // https://github.com/pinterest/ktlint - const val ktor = "2.3.7" // https://github.com/ktorio/ktor + const val ktor = "2.3.12" // https://github.com/ktorio/ktor const val multidex = "2.0.1" // https://developer.android.com/jetpack/androidx/releases/multidex const val nexusPublishPlugin = "1.1.0" // https://github.com/gradle-nexus/publish-plugin const val okio = "3.2.0" // https://square.github.io/okio/#releases const val relinker = "1.4.5" // https://github.com/KeepSafe/ReLinker - const val serialization = "1.6.0" // https://kotlinlang.org/docs/releases.html#release-details + const val serialization = "1.7.1" // https://kotlinlang.org/docs/releases.html#release-details const val shadowJar = "6.1.0" // https://mvnrepository.com/artifact/com.github.johnrengelman.shadow/com.github.johnrengelman.shadow.gradle.plugin?repo=gradle-plugins const val snakeYaml = "1.33" // https://github.com/snakeyaml/snakeyaml val sourceCompatibilityVersion = JavaVersion.VERSION_1_8 // Language level of any Java source code. diff --git a/packages/cinterop/src/jvmMain/generic.Dockerfile b/packages/cinterop/src/jvmMain/generic.Dockerfile deleted file mode 100644 index 26da693918..0000000000 --- a/packages/cinterop/src/jvmMain/generic.Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM centos:7 - -# Install EPEL & devtoolset -RUN yum install -y \ - epel-release \ - centos-release-scl-rh \ - && yum-config-manager --enable rhel-server-rhscl-7-rpms - -RUN yum install -y \ - chrpath \ - devtoolset-9 \ - jq \ - libconfig-devel \ - openssh-clients \ - rh-git218 \ - zlib-devel \ - java-1.8.0-openjdk-devel \ - && yum clean all - -ENV PATH /opt/cmake/bin:/opt/rh/rh-git218/root/usr/bin:/opt/rh/devtoolset-9/root/usr/bin:$PATH -ENV LD_LIBRARY_PATH /opt/rh/devtoolset-9/root/usr/lib64:/opt/rh/devtoolset-9/root/usr/lib:/opt/rh/devtoolset-9/root/usr/lib64/dyninst:/opt/rh/devtoolset-9/root/usr/lib/dyninst:/opt/rh/devtoolset-9/root/usr/lib64:/opt/rh/devtoolset-9/root/usr/lib - -RUN mkdir -p /opt/cmake \ - && curl https://cmake.org/files/v3.27/cmake-3.27.7-linux-x86_64.sh -o /cmake.sh \ - && sh /cmake.sh --prefix=/opt/cmake --skip-license \ - && rm /cmake.sh - -RUN mkdir -p /etc/ssh && \ - echo "Host github.com\n\tStrictHostKeyChecking no\n" >> /etc/ssh/ssh_config && \ - ssh-keyscan github.com >> /etc/ssh/ssh_known_hosts - diff --git a/packages/test-sync/build.gradle.kts b/packages/test-sync/build.gradle.kts index 8bf974864c..d8ad09ebe1 100644 --- a/packages/test-sync/build.gradle.kts +++ b/packages/test-sync/build.gradle.kts @@ -102,6 +102,8 @@ kotlin { implementation("io.ktor:ktor-serialization-kotlinx-json:${Versions.ktor}") implementation("io.ktor:ktor-client-content-negotiation:${Versions.ktor}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.serialization}") + implementation("com.squareup.okio:okio:${Versions.okio}") } } diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/TestApp.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/TestApp.kt index 9dc98af5b9..ab221414d6 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/TestApp.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/TestApp.kt @@ -19,7 +19,6 @@ package io.realm.kotlin.test.mongodb -import io.realm.kotlin.Realm import io.realm.kotlin.annotations.ExperimentalRealmSerializerApi import io.realm.kotlin.internal.interop.RealmInterop import io.realm.kotlin.internal.interop.SynchronizableObject @@ -31,8 +30,6 @@ import io.realm.kotlin.mongodb.AppConfiguration import io.realm.kotlin.mongodb.Credentials import io.realm.kotlin.mongodb.User import io.realm.kotlin.mongodb.internal.AppConfigurationImpl -import io.realm.kotlin.mongodb.sync.SyncConfiguration -import io.realm.kotlin.test.mongodb.common.FLEXIBLE_SYNC_SCHEMA import io.realm.kotlin.test.mongodb.util.AppAdmin import io.realm.kotlin.test.mongodb.util.AppAdminImpl import io.realm.kotlin.test.mongodb.util.AppServicesClient @@ -41,7 +38,6 @@ import io.realm.kotlin.test.mongodb.util.Service import io.realm.kotlin.test.mongodb.util.TestAppInitializer.initializeDefault import io.realm.kotlin.test.platform.PlatformUtils import io.realm.kotlin.test.util.TestHelper -import io.realm.kotlin.test.util.use import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -119,28 +115,6 @@ open class TestApp private constructor( ) ) - init { - // For apps with Flexible Sync, we need to bootstrap all the schemas to work around - // https://github.com/realm/realm-core/issues/7297. - // So we create a dummy Realm, upload all the schemas and close the Realm again. - if (app.configuration.appId.startsWith(TEST_APP_FLEX, ignoreCase = false)) { - runBlocking { - val user = app.login(Credentials.anonymous()) - val config = SyncConfiguration.create(user, FLEXIBLE_SYNC_SCHEMA) - try { - Realm.open(config).use { - // Using syncSession.uploadAllLocalChanges() seems to just hang forever. - // This is tracked by the above Core issue. Instead use the Sync Progress - // endpoint to signal when the schemas are ready. - pairAdminApp.second.waitForSyncBootstrap() - } - } finally { - user.delete() - } - } - } - } - fun createUserAndLogin(): User = runBlocking { val (email, password) = TestHelper.randomEmail() to "password1234" emailPasswordAuth.registerUser(email, password).run { diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt index cbef34d0d3..e15939520e 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppAdmin.kt @@ -18,9 +18,7 @@ package io.realm.kotlin.test.mongodb.util import io.realm.kotlin.mongodb.sync.SyncMode import io.realm.kotlin.mongodb.sync.SyncSession -import kotlinx.coroutines.delay import kotlinx.serialization.json.JsonObject -import kotlin.time.Duration.Companion.seconds /** * Wrapper around App Services Server Admin functions needed for tests. @@ -103,11 +101,6 @@ interface AppAdmin { */ suspend fun deleteDocuments(database: String, clazz: String, query: String): JsonObject? - /** - * Wait for Sync bootstrap to complete for all model classes. - */ - suspend fun waitForSyncBootstrap() - fun closeClient() } @@ -208,20 +201,6 @@ class AppAdminImpl( app.deleteDocument(database, clazz, query) } - override suspend fun waitForSyncBootstrap() { - baasClient.run { - var limit = 300 - var i = 0 - while (!app.initialSyncComplete() && i < limit) { - delay(1.seconds) - i++ - } - if (!app.initialSyncComplete()) { - throw IllegalStateException("Test server did not finish bootstrapping sync in time: $limit s.") - } - } - } - override fun closeClient() { baasClient.closeClient() } diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt index 58b770d1a5..eb6e02c732 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/AppServicesClient.kt @@ -35,19 +35,26 @@ import io.ktor.http.HttpMethod.Companion.Post import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json +import io.realm.kotlin.internal.interop.PropertyType import io.realm.kotlin.internal.platform.runBlocking +import io.realm.kotlin.internal.schema.RealmClassImpl import io.realm.kotlin.mongodb.sync.SyncMode +import io.realm.kotlin.schema.RealmClassKind import io.realm.kotlin.test.mongodb.SyncServerConfig import io.realm.kotlin.test.mongodb.TEST_APP_CLUSTER_NAME -import io.realm.kotlin.test.mongodb.common.FLEXIBLE_SYNC_SCHEMA_COUNT import io.realm.kotlin.test.mongodb.util.TestAppInitializer.initialize +import io.realm.kotlin.types.BaseRealmObject import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -58,12 +65,16 @@ import kotlinx.serialization.json.add import kotlinx.serialization.json.boolean import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import kotlinx.serialization.serializer +import kotlin.reflect.KClass +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds private const val ADMIN_PATH = "/api/admin/v3.0" @@ -72,6 +83,209 @@ data class SyncPermissions( val write: Boolean ) +@OptIn(ExperimentalSerializationApi::class) +private val json = Json { + classDiscriminatorMode = ClassDiscriminatorMode.NONE + encodeDefaults = true +} + +@Serializable +data class Schema( + val metadata: SchemaMetadata = SchemaMetadata( + database = "database", + collection = "title" + ), + val schema: SchemaData, + val relationships: Map = emptyMap(), +) { + constructor( + database: String, + schema: SchemaData, + relationships: Map, + ) : this( + metadata = SchemaMetadata( + database = database, + collection = schema.title + ), + schema = schema, + relationships = relationships + ) +} + +@Serializable +data class SchemaMetadata( + var database: String = "", + @SerialName("data_source") + var dataSource: String = "BackingDB", + var collection: String = "SyncDog", +) + +@Serializable +data class SchemaRelationship( + @SerialName("source_key") + val sourceKey: String, + @SerialName("foreign_key") + val foreignKey: String, + @SerialName("is_list") + val isList: Boolean, + val ref: String = "", +) { + constructor( + target: String, + database: String, + sourceKey: String, + foreignKey: String, + isList: Boolean, + ) : this( + sourceKey = sourceKey, + foreignKey = foreignKey, + isList = isList, + ref = "#/relationship/BackingDB/$database/$target" + ) +} + +@Serializable +sealed interface SchemaPropertyType { + @Transient val isRequired: Boolean +} + +@Serializable +class ObjectReferenceType( + @Transient val sourceKey: String = "", + @Transient val targetKey: String = "", + @Transient val target: String = "", + @Transient val isList: Boolean = false, + val bsonType: PrimitivePropertyType.Type, +) : SchemaPropertyType { + constructor(sourceKey: String, targetSchema: RealmClassImpl, isCollection: Boolean) : this( + sourceKey = sourceKey, + targetKey = targetSchema.cinteropClass.primaryKey, + target = targetSchema.name, + bsonType = targetSchema.cinteropProperties + .first { it.name == targetSchema.cinteropClass.primaryKey } + .type + .toSchemaType(), + isList = isCollection + ) + + @Transient + override val isRequired: Boolean = false +} + +@Serializable +data class SchemaData( + var title: String = "", + var properties: Map = mutableMapOf(), + val required: List = mutableListOf(), + @Transient val kind: RealmClassKind = RealmClassKind.STANDARD, + val type: PrimitivePropertyType.Type = PrimitivePropertyType.Type.OBJECT, +) : SchemaPropertyType { + @Transient + override val isRequired: Boolean = false +} + +@Serializable +data class CollectionPropertyType( + val items: SchemaPropertyType, + val uniqueItems: Boolean = false, +) : SchemaPropertyType { + val bsonType = PrimitivePropertyType.Type.ARRAY + @Transient + override val isRequired: Boolean = false +} + +@Serializable +data class MapPropertyType( + val additionalProperties: SchemaPropertyType, +) : SchemaPropertyType { + val bsonType = PrimitivePropertyType.Type.OBJECT + @Transient + override val isRequired: Boolean = false +} + +@Serializable +open class PrimitivePropertyType( + val bsonType: Type, + @Transient override val isRequired: Boolean = false, +) : SchemaPropertyType { + + enum class Type { + @SerialName("string") + STRING, + + @SerialName("object") + OBJECT, + + @SerialName("array") + ARRAY, + + @SerialName("objectId") + OBJECT_ID, + + @SerialName("boolean") + BOOLEAN, + + @SerialName("bool") + BOOL, + + @SerialName("null") + NULL, + + @SerialName("regex") + REGEX, + + @SerialName("date") + DATE, + + @SerialName("timestamp") + TIMESTAMP, + + @SerialName("int") + INT, + + @SerialName("long") + LONG, + + @SerialName("decimal") + DECIMAL, + + @SerialName("double") + DOUBLE, + + @SerialName("number") + NUMBER, + + @SerialName("binData") + BIN_DATA, + + @SerialName("uuid") + UUID, + + @SerialName("mixed") + MIXED, + + @SerialName("float") + FLOAT; + } +} + +fun PropertyType.toSchemaType() = + when (this) { + PropertyType.RLM_PROPERTY_TYPE_BOOL -> PrimitivePropertyType.Type.BOOL + PropertyType.RLM_PROPERTY_TYPE_INT -> PrimitivePropertyType.Type.INT + PropertyType.RLM_PROPERTY_TYPE_STRING -> PrimitivePropertyType.Type.STRING + PropertyType.RLM_PROPERTY_TYPE_BINARY -> PrimitivePropertyType.Type.BIN_DATA + PropertyType.RLM_PROPERTY_TYPE_OBJECT -> PrimitivePropertyType.Type.OBJECT + PropertyType.RLM_PROPERTY_TYPE_FLOAT -> PrimitivePropertyType.Type.FLOAT + PropertyType.RLM_PROPERTY_TYPE_DOUBLE -> PrimitivePropertyType.Type.DOUBLE + PropertyType.RLM_PROPERTY_TYPE_DECIMAL128 -> PrimitivePropertyType.Type.DECIMAL + PropertyType.RLM_PROPERTY_TYPE_TIMESTAMP -> PrimitivePropertyType.Type.DATE + PropertyType.RLM_PROPERTY_TYPE_OBJECT_ID -> PrimitivePropertyType.Type.OBJECT_ID + PropertyType.RLM_PROPERTY_TYPE_UUID -> PrimitivePropertyType.Type.UUID + PropertyType.RLM_PROPERTY_TYPE_MIXED -> PrimitivePropertyType.Type.MIXED + else -> throw IllegalArgumentException("Unsupported type") + } + @Serializable data class LoginResponse(val access_token: String) @@ -207,6 +421,8 @@ class AppServicesClient( unauthorizedClient.close() val httpClient = defaultClient("realm-baas-authorized", debug) { + expectSuccess = true + defaultRequest { headers { append("Authorization", "Bearer $accessToken") @@ -279,6 +495,45 @@ class AppServicesClient( } } + suspend fun BaasApp.setSchema( + schema: Set>, + extraProperties: Map = emptyMap() + ) { + val schemas = SchemaProcessor.process( + databaseName = clientAppId, + classes = schema, + extraProperties = extraProperties + ) + + // First we create the schemas without the relationships + val ids: Map = schemas.entries + .associate { (name, schema: Schema) -> + name to addSchema(schema = schema.copy(relationships = emptyMap())) + } + + // then we update the schema to add the relationships + schemas.forEach { (name, schema) -> + updateSchema( + id = ids[name]!!, + schema = schema + ) + } + } + + suspend fun BaasApp.updateSchema( + id: String, + schema: Schema, + ): HttpResponse = + withContext(dispatcher) { + httpClient.request( + "$url/schemas/$id" + ) { + this.method = HttpMethod.Put + setBody(json.encodeToJsonElement(schema)) + contentType(ContentType.Application.Json) + } + } + val BaasApp.url: String get() = "$groupUrl/apps/${this._id}" @@ -293,15 +548,17 @@ class AppServicesClient( } } - suspend fun BaasApp.addSchema(schema: String): JsonObject = + suspend fun BaasApp.addSchema(schema: Schema): String = withContext(dispatcher) { httpClient.typedRequest( Post, "$url/schemas" ) { - setBody(Json.parseToJsonElement(schema)) + setBody(json.encodeToJsonElement(schema)) contentType(ContentType.Application.Json) } + }.let { jsonObject: JsonObject -> + jsonObject["_id"]!!.jsonPrimitive.content } suspend fun BaasApp.addService(service: String): Service = @@ -641,6 +898,14 @@ class AppServicesClient( ) } + suspend fun BaasApp.waitUntilInitialSyncCompletes() { + withTimeout(5.minutes) { + while (!initialSyncComplete()) { + delay(1.seconds) + } + } + } + suspend fun BaasApp.initialSyncComplete(): Boolean { return withContext(dispatcher) { try { @@ -648,22 +913,7 @@ class AppServicesClient( Get, "$url/sync/progress" ).let { obj: JsonObject -> - val statuses: JsonElement = obj["progress"]!! - when (statuses) { - is JsonObject -> { - if (statuses.keys.isEmpty()) { - // It might take a few seconds to register the Schemas, so treat - // "empty" progress as initial sync not being complete (as we always - // have at least one pre-defined schema). - false - } - val bootstrapComplete: List = statuses.keys.map { schemaClass -> - statuses[schemaClass]!!.jsonObject["complete"]?.jsonPrimitive?.boolean == true - } - bootstrapComplete.all { it } && statuses.size == FLEXIBLE_SYNC_SCHEMA_COUNT - } - else -> false - } + obj["accepting_clients"]?.jsonPrimitive?.boolean ?: false } } catch (ex: IllegalStateException) { if (ex.message!!.contains("there are no mongodb/atlas services with provided sync state")) { diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/SchemaProcessor.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/SchemaProcessor.kt new file mode 100644 index 0000000000..9cc9b17bf6 --- /dev/null +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/SchemaProcessor.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2024 Realm Inc. + * + * 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. + */ +@file:Suppress("invisible_member", "invisible_reference") + +package io.realm.kotlin.test.mongodb.util + +import io.realm.kotlin.internal.interop.CollectionType +import io.realm.kotlin.internal.interop.PropertyInfo +import io.realm.kotlin.internal.interop.PropertyType +import io.realm.kotlin.internal.realmObjectCompanionOrNull +import io.realm.kotlin.internal.schema.RealmClassImpl +import io.realm.kotlin.schema.RealmClassKind +import io.realm.kotlin.types.BaseRealmObject +import kotlin.reflect.KClass + +class SchemaProcessor private constructor( + classes: Set>, + private val databaseName: String, + private val extraProperties: Map, +) { + companion object { + + fun process( + databaseName: String, + classes: Set>, + extraProperties: Map = emptyMap(), + ): Map { + val processor = SchemaProcessor(classes, databaseName, extraProperties) + + return processor.processedSchemas + .entries + .filterNot { (_, schema) -> schema.kind == RealmClassKind.EMBEDDED } + .associate { (name, schema) -> + // add metadata + name to Schema( + databaseName, + schema, + processor.processedRelationships[name]!! + ) + } + } + } + + private val realmSchemas: Map = classes.associate { clazz -> + val companion = clazz.realmObjectCompanionOrNull()!! + val realmSchema = companion.io_realm_kotlin_schema() + realmSchema.cinteropClass.name to realmSchema + } + + val processedSchemas: MutableMap = mutableMapOf() + val processedRelationships: MutableMap> = mutableMapOf() + + init { + checkCycles() + generateSchemas() + generateRelationships() + } + + private fun checkCycles() { + realmSchemas.values.filter { + it.kind == RealmClassKind.EMBEDDED + }.forEach { schema -> + checkCycles(schema) + } + } + + private fun checkCycles(schema: RealmClassImpl, visited: Array = emptyArray()) { + if (visited.contains(schema.cinteropClass.name)) + throw IllegalStateException("Cycles in embedded object schemas are not supported") + + schema.cinteropProperties + .filter { targetSchema -> + targetSchema.type == PropertyType.RLM_PROPERTY_TYPE_OBJECT + } + .forEach { targetSchema -> + checkCycles(realmSchemas[targetSchema.linkTarget]!!, visited + schema.name) + } + } + + private fun generateRelationships() { + processedSchemas.values.forEach { schema -> + processedRelationships[schema.title] = + findRelationships(schema.properties).associateBy { it.sourceKey } + } + } + + private fun findRelationships( + properties: Map, + path: String = "", + ): List = + properties.entries + .filterNot { (_, value) -> + value is PrimitivePropertyType + } + .flatMap { (key, value: SchemaPropertyType) -> + value.toSchemaRelationships(key, path) + } + + private fun SchemaPropertyType.toSchemaRelationships( + key: String, + path: String = "", + ): List { + return when (this) { + is ObjectReferenceType -> listOf(toSchemaRelationship(path)) + is CollectionPropertyType -> items.toSchemaRelationships("$path$key.[]") + is MapPropertyType -> additionalProperties.toSchemaRelationships("$path$key.[]") + is SchemaData -> findRelationships(properties, "$path$key.") + else -> emptyList() + } + } + + private fun ObjectReferenceType.toSchemaRelationship(path: String = "") = + SchemaRelationship( + database = databaseName, + target = target, + sourceKey = "$path$sourceKey", + foreignKey = targetKey, + isList = isList + ) + + private fun generateSchemas() { + realmSchemas.forEach { entry -> + if (entry.key !in processedSchemas) + entry.value.toSchema() + } + } + + private fun RealmClassImpl.toSchema() { + val name = cinteropClass.name + + val properties: Map = cinteropProperties + .filterNot { + it.isComputed + } + .associate { property: PropertyInfo -> + property.name to property.toSchemaProperty() + } + when (kind) { + RealmClassKind.STANDARD -> + extraProperties.entries.associate { + it.key to PrimitivePropertyType( + bsonType = it.value, + isRequired = false, + ) + } + + RealmClassKind.EMBEDDED -> emptyMap() + RealmClassKind.ASYMMETRIC -> emptyMap() + } + + val required: List = properties.entries + .filter { (_, value) -> + value.isRequired + } + .map { (name, _) -> name } + + processedSchemas[name] = SchemaData( + title = name, + properties = properties, + required = required, + kind = kind + ) + } + + private fun PropertyInfo.toSchemaProperty(): SchemaPropertyType = + when (collectionType) { + CollectionType.RLM_COLLECTION_TYPE_NONE -> propertyValueType() + CollectionType.RLM_COLLECTION_TYPE_LIST -> CollectionPropertyType( + items = propertyValueType(isCollection = true), + uniqueItems = false + ) + + CollectionType.RLM_COLLECTION_TYPE_SET -> CollectionPropertyType( + items = propertyValueType(isCollection = true), + uniqueItems = true + ) + + CollectionType.RLM_COLLECTION_TYPE_DICTIONARY -> MapPropertyType( + additionalProperties = propertyValueType(isCollection = true) + ) + + else -> throw IllegalStateException("Unsupported $collectionType") + } + + private fun PropertyInfo.propertyValueType(isCollection: Boolean = false): SchemaPropertyType = + if (type == PropertyType.RLM_PROPERTY_TYPE_OBJECT) + realmSchemas[linkTarget]!! + .let { targetSchema: RealmClassImpl -> + when (targetSchema.kind) { + RealmClassKind.STANDARD -> ObjectReferenceType( + name, + targetSchema, + isCollection + ) + + RealmClassKind.EMBEDDED -> getSchema(targetSchema.name) + RealmClassKind.ASYMMETRIC -> TODO() + } + } + else + PrimitivePropertyType( + bsonType = type.toSchemaType(), + isRequired = !isNullable + ) + + private fun getSchema(name: String): SchemaData { + if (name !in processedSchemas) + realmSchemas[name]!!.toSchema() + + return processedSchemas[name]!! + } +} diff --git a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt index 60d9dfb223..5116d6e406 100644 --- a/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt +++ b/packages/test-sync/src/commonMain/kotlin/io/realm/kotlin/test/mongodb/util/TestAppInitializer.kt @@ -18,7 +18,8 @@ package io.realm.kotlin.test.mongodb.util import io.realm.kotlin.test.mongodb.TEST_APP_CLUSTER_NAME import io.realm.kotlin.test.mongodb.TEST_APP_FLEX import io.realm.kotlin.test.mongodb.TEST_APP_PARTITION -import kotlinx.serialization.decodeFromString +import io.realm.kotlin.test.mongodb.common.FLEXIBLE_SYNC_SCHEMA +import io.realm.kotlin.test.mongodb.common.PARTITION_BASED_SCHEMA import kotlinx.serialization.json.Json object TestAppInitializer { @@ -36,9 +37,12 @@ object TestAppInitializer { suspend fun AppServicesClient.initializeFlexibleSync( app: BaasApp, service: Service, - recoveryDisabled: Boolean = false // TODO + recoveryDisabled: Boolean = false ) { val databaseName = app.clientAppId + + app.setSchema(FLEXIBLE_SYNC_SCHEMA) + service.setSyncConfig( """ { @@ -48,12 +52,21 @@ object TestAppInitializer { "is_recovery_mode_disabled": $recoveryDisabled, "queryable_fields_names": [ "name", - "section" + "section", + "stringField", + "location", + "selector" + ], + "asymmetric_tables": [ + "AsymmetricA", + "Measurement" ] } } """.trimIndent() ) + + app.waitUntilInitialSyncCompletes() } @Suppress("LongMethod") @@ -67,6 +80,13 @@ object TestAppInitializer { app.addFunction(canReadPartition) app.addFunction(canWritePartition) + app.setSchema( + schema = PARTITION_BASED_SCHEMA, + extraProperties = mapOf( + "realm_id" to PrimitivePropertyType.Type.STRING + ) + ) + service.setSyncConfig( """ { @@ -105,88 +125,7 @@ object TestAppInitializer { """.trimIndent() ) - app.addSchema( - """ - { - "metadata": { - "data_source": "BackingDB", - "database": "$databaseName", - "collection": "SyncDog" - }, - "schema": { - "properties": { - "_id": { - "bsonType": "objectId" - }, - "breed": { - "bsonType": "string" - }, - "name": { - "bsonType": "string" - }, - "realm_id": { - "bsonType": "string" - } - }, - "required": [ - "name" - ], - "title": "SyncDog" - } - } - """.trimIndent() - ) - - app.addSchema( - """ - { - "metadata": { - "data_source": "BackingDB", - "database": "$databaseName", - "collection": "SyncPerson" - }, - "relationships": { - "dogs": { - "ref": "#/relationship/BackingDB/$databaseName/SyncDog", - "source_key": "dogs", - "foreign_key": "_id", - "is_list": true - } - }, - "schema": { - "properties": { - "_id": { - "bsonType": "objectId" - }, - "age": { - "bsonType": "int" - }, - "dogs": { - "bsonType": "array", - "items": { - "bsonType": "objectId" - } - }, - "firstName": { - "bsonType": "string" - }, - "lastName": { - "bsonType": "string" - }, - "realm_id": { - "bsonType": "string" - } - }, - "required": [ - "firstName", - "lastName", - "age" - ], - "title": "SyncPerson" - } - } - """.trimIndent() - ) + app.waitUntilInitialSyncCompletes() } suspend fun AppServicesClient.addEmailProvider( @@ -295,8 +234,6 @@ object TestAppInitializer { block(app, service) } - - setDevelopmentMode(true) } private val insertDocument = Function( diff --git a/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SchemaProcessorTests.kt b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SchemaProcessorTests.kt new file mode 100644 index 0000000000..f7b19a18f7 --- /dev/null +++ b/packages/test-sync/src/commonTest/kotlin/io/realm/kotlin/test/mongodb/common/SchemaProcessorTests.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Realm Inc. + * + * 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 io.realm.kotlin.test.mongodb.common + +import io.realm.kotlin.test.mongodb.common.utils.assertFailsWithMessage +import io.realm.kotlin.test.mongodb.util.SchemaProcessor +import io.realm.kotlin.types.EmbeddedRealmObject +import kotlin.test.Test + +class CycleEmbeddedObject1 : EmbeddedRealmObject { + var name: String = "" + var o1: CycleEmbeddedObject2 = CycleEmbeddedObject2() +} + +class CycleEmbeddedObject2 : EmbeddedRealmObject { + var name: String = "" + var o1: CycleEmbeddedObject3 = CycleEmbeddedObject3() +} + +class CycleEmbeddedObject3 : EmbeddedRealmObject { + var name: String = "" + var o1: CycleEmbeddedObject1 = CycleEmbeddedObject1() +} + +class NoCycleEmbeddedObject1 : EmbeddedRealmObject { + var name: String = "" + var o1: NoCycleEmbeddedObject2 = NoCycleEmbeddedObject2() +} + +class NoCycleEmbeddedObject2 : EmbeddedRealmObject { + var name: String = "" +} + +class SchemaProcessorTests { + + @Test + fun cyclesThrow() { + assertFailsWithMessage("Cycles in embedded object schemas are not supported") { + SchemaProcessor.process( + "", + classes = setOf( + CycleEmbeddedObject1::class, + CycleEmbeddedObject2::class, + CycleEmbeddedObject3::class + ) + ) + } + } + + @Test + fun noCyclesDoesntThrow() { + SchemaProcessor.process( + "", + classes = setOf(NoCycleEmbeddedObject1::class, NoCycleEmbeddedObject2::class) + ) + } +}