diff --git a/firebase-dataconnect/.gitignore b/firebase-dataconnect/.gitignore new file mode 100644 index 00000000000..03f265a459f --- /dev/null +++ b/firebase-dataconnect/.gitignore @@ -0,0 +1 @@ +dataconnect.local.properties diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md new file mode 100644 index 00000000000..1a8bb471488 --- /dev/null +++ b/firebase-dataconnect/CHANGELOG.md @@ -0,0 +1,17 @@ +# Unreleased +* [feature] Initial release of the Data Connect SDK (public preview). Learn how to + [get started](https://firebase.google.com/docs/data-connect/android-sdk) + with the SDK in your app. +* [feature] Added App Check support. + ([#6176](https://github.com/firebase/firebase-android-sdk/pull/6176)) +* [feature] Added `AnyValue` to support the `Any` custom GraphQL scalar type. + ([#6285](https://github.com/firebase/firebase-android-sdk/pull/6285)) +* [feature] Added ability to specify `SerializersModule` when serializing. + ([#6297](https://github.com/firebase/firebase-android-sdk/pull/6297)) +* [feature] Added `CallerSdkType`, which enables tracking of the generated SDK usage. + ([#6298](https://github.com/firebase/firebase-android-sdk/pull/6298) and + [#6179](https://github.com/firebase/firebase-android-sdk/pull/6179)) +* [changed] Changed gRPC proto package to v1beta (was v1alpha). + ([#6299](https://github.com/firebase/firebase-android-sdk/pull/6299)) +* [changed] Added `equals` and `hashCode` methods to `GeneratedConnector`. + ([#6177](https://github.com/firebase/firebase-android-sdk/pull/6177)) diff --git a/firebase-dataconnect/README.md b/firebase-dataconnect/README.md new file mode 100644 index 00000000000..d2242198048 --- /dev/null +++ b/firebase-dataconnect/README.md @@ -0,0 +1,110 @@ +# firebase-dataconnect + +This is the Firebase Android Data Connect SDK. + +## Building + +All Gradle commands should be run from the source root (which is one level up +from this folder). See the README.md in the source root for instructions on +publishing/testing Firebase Data Connect. + +To build Firebase Data Connect, from the source root run: +```bash +./gradlew :firebase-dataconnect:assembleRelease +``` + +## Unit Testing + +To run unit tests for Firebase Data Connect, from the source root run: +```bash +./gradlew :firebase-dataconnect:check +``` + +## Integration Testing + +Running integration tests requires a Firebase project because they connect to +the Firebase Data Connect backend. + +See [here](../README.md#project-setup) for how to setup a project. + +Once you setup the project, download `google-services.json` and place it in +the source root. + +Make sure you have created a Firebase Data Connect instance for your project, +before you proceed. + +By default, integration tests run against the Firebase Data Connect emulator. + +### Setting up the Firebase Data Connect Emulator + +The integration tests require that the Firebase Data Connect emulator is running +on port 9399, which is default when running it via the Data Connect Toolkit. + + * [Install the Firebase CLI](https://firebase.google.com/docs/cli/). + ``` + npm install -g firebase-tools + ``` + * [Install the Firebase Data Connect + emulator](https://firebase.google.com/docs/FIX_URL/security/test-rules-emulator#install_the_emulator). + ``` + firebase setup:emulators:dataconnect + ``` + * Run the emulator + ``` + firebase emulators:start --only dataconnect + ``` + * Select the `Firebase Data Connect Integration Tests (Firebase Data Connect + Emulator)` run configuration to run all integration tests. + +To run the integration tests against prod, select +`DataConnectProdIntegrationTest` run configuration. + +### Run on Local Android Emulator + +Then run: +```bash +./gradlew :firebase-dataconnect:connectedCheck +``` + +### Run on Firebase Test Lab + +You can also test on Firebase Test Lab, which allow you to run the integration +tests on devices hosted in a Google data center. + +See [here](../README.md#running-integration-tests-on-firebase-test-lab) for +instructions of how to setup Firebase Test Lab for your project. + +Run: +```bash +./gradlew :firebase-dataconnect:deviceCheck +``` + +## Code Formatting + +Run below to format Kotlin and Java code: +```bash +./gradlew :firebase-dataconnect:spotlessApply +``` + +See [here](../README.md#code-formatting) if you want to be able to format code +from within Android Studio. + +## Build Local Jar of Firebase Data Connect SDK + +```bash +./gradlew -PprojectsToPublish="firebase-dataconnect" publishReleasingLibrariesToMavenLocal +``` + +Developers may then take a dependency on these locally published versions by adding +the `mavenLocal()` repository to your [repositories +block](https://docs.gradle.org/current/userguide/declaring_repositories.html) in +your app module's build.gradle. + +## Misc +After importing the project into Android Studio and building successfully +for the first time, Android Studio will delete the run configuration xml files +in `./idea/runConfigurations`. Undo these changes with the command: + +``` +$ git checkout .idea/runConfigurations +``` diff --git a/firebase-dataconnect/androidTestutil/androidTestutil.gradle.kts b/firebase-dataconnect/androidTestutil/androidTestutil.gradle.kts new file mode 100644 index 00000000000..2bc2583c8c7 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/androidTestutil.gradle.kts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.kotlinx.serialization) +} + +android { + val compileSdkVersion : Int by rootProject + val targetSdkVersion : Int by rootProject + val minSdkVersion : Int by rootProject + + namespace = "com.google.firebase.dataconnect.androidTestutil" + compileSdk = compileSdkVersion + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + + packaging { + resources { + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") + } + } +} + +dependencies { + implementation(project(":firebase-dataconnect")) + implementation(project(":firebase-dataconnect:testutil")) + + implementation("com.google.firebase:firebase-auth:22.3.1") + implementation("com.google.firebase:firebase-appcheck:18.0.0") + + implementation(libs.androidx.test.core) + implementation(libs.androidx.test.junit) + implementation(libs.auth0.jwt) + implementation(libs.kotest.property) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.truth) + implementation(libs.turbine) +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} diff --git a/firebase-dataconnect/androidTestutil/src/main/AndroidManifest.xml b/firebase-dataconnect/androidTestutil/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..3272df4cc1f --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/firebase-dataconnect/androidTestutil/src/main/assets/firebase-admin-service-account.key.json b/firebase-dataconnect/androidTestutil/src/main/assets/firebase-admin-service-account.key.json new file mode 100644 index 00000000000..fa56ce5d9e0 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/assets/firebase-admin-service-account.key.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "prjh5zbv64sv6", + "private_key_id": "abcdef0123456789abcdef0123456789abcdef01", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCvfyKRUZMyp1FS\nbL2cZIMNA5AbDHBuZciEZFIbG7rx7d12Lnwz/inBSuhJ2qzmSHvWLQfjV0xsPVEH\nCCQdB7owt8GemvcyrzaW/vQ5WmvN7Wpj7Efz7iEguXQfqa09FklGECfrhgnOdsdd\ntoQW19nzETwepXzBvo6C/etTesUHAjBLUeHoh94vDvMTbp+9Dc48pG+uMhSVItjV\nv32lDRemFcewK33SCYWG2+3CqEQHlFf75pnbMTJr0392KLJtXqv6Kgcd61JXB7l8\nw8G4GPm8o9er0l4j5lB36Az1SmAJM68K7lI98PMrCcSsdwgNT3R16J6y+zMJfc5h\nStGdJP5hAgMBAAECggEARGEtl2CpEYoHEi4jfS3esDHsstVYc3N+OzOZmE1oPH65\nlSRMqbeFDncA5lHpn3qrocp+8dJgiSYlDa/a3mLV5cibjRCFc/64LwJdJ4G3UpAI\nrbFxYbatusH34KRsx0oJN97wpwDdjlBSow2MDxiAqAhVm/1QDG+SuLB2QlsqLO3E\ntDHgix+x08b01ui7/QYm83y1qTUTeCq/JlpRcMe4Nqp8RJiVTu+OY9MVJeA7o/ng\nLYnjTI0u1kB346EClTvq2xSb0h5AENtAd61B7H65JtkQWB5uDHL3HrWAbVVldp21\ncH6sO66/ApY4v0KGalgbBZ6VzmVuzVp7Kl+0t+m0/wKBgQDVdmz77vdjsTG7LEqC\nsknEKOTXSJYpA6g4dCouwHGrS4EkNzCXaAOCAdOoPkBF991uNNSqPtaDDMqF5CpA\nvJKzANBn1AuGp9jimITfA82KtEbA3t7yCk6A6+sAJNHsVA5I0+p/wcO0VmVZGIoN\n2pIHOTVbytcfAgHG0CjMv2SbJwKBgQDSd+n/sdTNFcTe8KoRJP2N9UFGip/9GZrV\nn9SUZGHojYrCY8DKI0GtAgR6Lij9D9CJRTPDSOEMuNpyPQQNFa5Sa14ic6dcksNg\nG6cq1BaaqXE7nxzVvgrOBXAxnRudd7rI/JoEsrG+Ca23HkvuCKsydjbNs6GY9hfE\nSfMnrsivNwKBgQDBJDAkG+pXl6Jpuv+IFg1Mobu9Vv4XCioROnpYZuPym5Sz0gPz\nWreh0ElUd07sgAMojkDF8aliVhaA4xugC3+o21m2OFRdeE1zaZD/wI8fq1JBfOa4\nlb7GQ7AUJzyR2tQ57RTGl+mdqHZ3EQ8IzfVG9+phrbzLX6N/4iSobZx4DQKBgEYY\nn/uD+67OOEJT/yA0pKnZ7AKVetFt7K6HS+KcSCuOsI8rb/MiqOX5DQqwQwB9euOt\nA59fr2xwSHjRr364INXcYn6w7CWdz6o7q4JNHrYmBstno8/gOnMBRquPeroIPVJh\nJt63sRDs4klhssI1auckjf4WfJSYKbQ7ONuXj8kjAoGBAILZG9+YNZ9IKLtQzcdf\nbWzgQ2b9CujHdZ5agSGUHVeKSSIInQZAc5jRCKz35T9Xnh50qrixcNA90IvpjbGL\nCNmmvmB+IlIuH/Mzn6wb5fad8d80e1Yz+ueeAyZjMS6NYLwmZ1M52eeikRWdyO/h\nZ3q6UYLR9K+mhUFgV+X7g15T\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-trn2rx6xed@prjh5zbv64sv6.iam.gserviceaccount.com", + "client_id": "123456789012345678901", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-trn2rx6xed%40prjh5zbv64sv6.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackend.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackend.kt new file mode 100644 index 00000000000..a62e1c6008f --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackend.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import androidx.annotation.VisibleForTesting +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.copy +import com.google.firebase.dataconnect.getInstance +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Autopush +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Companion.fromInstrumentationArguments +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Custom +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Emulator +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Production +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Staging +import java.net.MalformedURLException +import java.net.URI +import java.net.URISyntaxException +import java.net.URL + +/** + * The various Data Connect backends against which integration tests can run. + * + * The integration tests generally determine which backend to use by calling + * [fromInstrumentationArguments], which returns [Emulator] by default. This default, however, can + * be overridden by specifying the `DATA_CONNECT_BACKEND` instrumentation argument. This argument + * can take on the following values: + * - `prod` ([Production]) - the Firebase Data Connect _production_ server. + * - `staging` ([Staging]) - the Firebase Data Connect _staging_ server. + * - `autopush` ([Autopush]) - the Firebase Data Connect _autopush_ server. + * - `emulator` ([Emulator]) - the Firebase Data Connect _emulator_, running on the default port. + * - `emulator://[host][:port]` ([Emulator]) - the Firebase Data Connect _emulator_, running on the + * given host and/or port (uses the default host and/or port, if not specified). + * - `http://host[:port]` ([Custom]) - the Firebase Data Connect server running on the given host, + * optionally on the given port (default: 80) with sslEnabled=false. + * - `https://host[:port]` ([Custom]) - the Firebase Data Connect server running on the given host, + * optionally on the given port (default: 443) with sslEnabled=true. + * + * The instrumentation test argument can be set on the Gradle command-line by specifying + * ``` + * -Pandroid.testInstrumentationRunnerArguments.DATA_CONNECT_BACKEND=[backend] + * ``` + * where `[backend]` is one of the values specified above. For example, to run against production, + * the tests could be run as follows: + * ``` + * ./gradlew :firebase-dataconnect:connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.DATA_CONNECT_BACKEND=prod + * ``` + * + * The instrumentation test argument can also be set in Android Studio's run configuration. Simply + * open the run configuration for the integration tests whose backend you want to customize and add + * the `DATA_CONNECT_BACKEND` key/value pair to the "instrumentation arguments". See the following + * screenshots for a walkthrough: + * + * - + * https://github.com/firebase/firebase-android-sdk/assets/61283819/2bcb272b-16cc-4715-ad69-a4654e08b02e + * - + * https://github.com/firebase/firebase-android-sdk/assets/61283819/a8766c6d-b289-4d16-a96e-d012f4acd872 + * - + * https://github.com/firebase/firebase-android-sdk/assets/61283819/bdf1b721-a600-49ab-9e52-bf50ae05ac3e + * + * Googlers can see these screenshots, if the screenshots above ever get garbage collected: + * + * - https://screenshot.googleplex.com/9nTdBTgiojbgisu + * - https://screenshot.googleplex.com/AmNdgDkWmR4gQXr + * - https://screenshot.googleplex.com/8Aq5YKUXCLUAjKr + * + * When using "autopush" or "staging", the `firebase-tools` cli must be told about the URL of the + * Data Connect server to which to deploy using the `FIREBASE_DATACONNECT_URL` environment variable. + * This only matters if running a command line `firebase deploy --only dataconnect` or other + * `firebase` commands that talk to a Data Connect backend. See the documentation of [Staging] and + * [Autopush] for details. + */ +sealed interface DataConnectBackend { + + val dataConnectSettings: DataConnectSettings + val authBackend: FirebaseAuthBackend + + fun getDataConnect(app: FirebaseApp, config: ConnectorConfig): FirebaseDataConnect = + FirebaseDataConnect.getInstance(app, config, dataConnectSettings) + + /** The "production" Data Connect server, which is used by customers. */ + object Production : DataConnectBackend { + override val dataConnectSettings + get() = DataConnectSettings() + override val authBackend: FirebaseAuthBackend + get() = FirebaseAuthBackend.Production + override fun toString() = "DataConnectBackend.Production" + } + + sealed class PredefinedDataConnectBackend(val host: String) : DataConnectBackend { + override val dataConnectSettings + get() = DataConnectSettings().copy(host = host, sslEnabled = true) + override val authBackend: FirebaseAuthBackend + get() = FirebaseAuthBackend.Production + } + + /** + * The "staging" Data Connect server, which is updated roughly weekly with the latest code. + * + * In order to instruct firebase-tools to run against the staging backend, set the environment + * variable `FIREBASE_DATACONNECT_URL=https://staging-firebasedataconnect.sandbox.googleapis.com` + */ + object Staging : + PredefinedDataConnectBackend("staging-firebasedataconnect.sandbox.googleapis.com") { + override fun toString() = "DataConnectBackend.Staging($host)" + } + + /** + * The "autopush" Data Connect server, which is updated every 2 hours with the latest code + * + * In order to instruct firebase-tools to run autopush the staging backend, set the environment + * variable `FIREBASE_DATACONNECT_URL=https://autopush-firebasedataconnect.sandbox.googleapis.com` + */ + object Autopush : + PredefinedDataConnectBackend("autopush-firebasedataconnect.sandbox.googleapis.com") { + override fun toString() = "DataConnectBackend.Autopush($host)" + } + + /** A custom Data Connect server. */ + data class Custom(val host: String, val sslEnabled: Boolean) : DataConnectBackend { + override val dataConnectSettings + get() = DataConnectSettings().copy(host = host, sslEnabled = sslEnabled) + override val authBackend: FirebaseAuthBackend + get() = FirebaseAuthBackend.Production + override fun toString() = "DataConnectBackend.Custom(host=$host, sslEnabled=$sslEnabled)" + } + + /** The Data Connect emulator. */ + data class Emulator(val host: String? = null, val port: Int? = null) : DataConnectBackend { + override val dataConnectSettings + get() = DataConnectSettings() + override val authBackend: FirebaseAuthBackend + get() = FirebaseAuthBackend.Emulator() + override fun toString() = "DataConnectBackend.Emulator(host=$host, port=$port)" + + override fun getDataConnect(app: FirebaseApp, config: ConnectorConfig): FirebaseDataConnect = + super.getDataConnect(app, config).apply { + if (host !== null && port !== null) { + useEmulator(host = host, port = port) + } else if (host !== null) { + useEmulator(host = host) + } else if (port !== null) { + useEmulator(port = port) + } else { + useEmulator() + } + } + } + + companion object { + + /** + * The name of the instrumentation argument that can be set to override the Data Connect backend + * to use. + */ + private const val INSTRUMENTATION_ARGUMENT = "DATA_CONNECT_BACKEND" + + /** + * Returns the Data Connect backend to use, according to the [INSTRUMENTATION_ARGUMENT] + * instrumentation argument, or [Emulator] if the instrumentation argument is not set. + * + * This method should generally be called by integration tests to determine which Data Connect + * backend to use. + */ + fun fromInstrumentationArguments(): DataConnectBackend { + val argument = getInstrumentationArgument(INSTRUMENTATION_ARGUMENT) + return fromInstrumentationArgument(argument) ?: Emulator() + } + + private fun URL.hostOrNull(): String? = host.ifEmpty { null } + private fun URL.portOrNull(): Int? = port.let { if (it > 0) it else null } + + @VisibleForTesting + internal fun fromInstrumentationArgument(arg: String?): DataConnectBackend? { + if (arg === null) { + return null + } + + when (arg) { + "prod" -> return Production + "staging" -> return Staging + "autopush" -> return Autopush + "emulator" -> return Emulator() + } + + val uri = + try { + URI(arg) + } catch (e: URISyntaxException) { + throw InvalidInstrumentationArgumentException( + INSTRUMENTATION_ARGUMENT, + arg, + "cannot be parsed as a URI", + e + ) + } + + if (uri.scheme == "emulator") { + val url = + try { + URL("https://${uri.schemeSpecificPart}") + } catch (e: MalformedURLException) { + throw InvalidInstrumentationArgumentException( + INSTRUMENTATION_ARGUMENT, + arg, + "invalid 'emulator' URI", + e + ) + } + return Emulator(host = url.hostOrNull(), port = url.portOrNull()) + } + + val url = + try { + URL(arg) + } catch (e: MalformedURLException) { + throw InvalidInstrumentationArgumentException( + INSTRUMENTATION_ARGUMENT, + arg, + "cannot be parsed as a URL", + e + ) + } + + val host = url.hostOrNull() + val port = url.portOrNull() + val sslEnabled = + when (url.protocol) { + "http" -> false + "https" -> true + else -> + throw InvalidInstrumentationArgumentException( + INSTRUMENTATION_ARGUMENT, + arg, + "unsupported protocol: ${url.protocol}", + null + ) + } + + val customHost = + if (host !== null && port !== null) { + "$host:$port" + } else if (host !== null) { + host + } else if (port !== null) { + ":$port" + } else { + throw InvalidInstrumentationArgumentException( + INSTRUMENTATION_ARGUMENT, + arg, + "a host and/or a port must be specified", + null + ) + } + + return Custom(host = customHost, sslEnabled = sslEnabled) + } + } +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectIntegrationTestBase.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectIntegrationTestBase.kt new file mode 100644 index 00000000000..a82b5f7cf0e --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectIntegrationTestBase.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.util.nextAlphanumericString +import io.kotest.property.RandomSource +import kotlin.random.Random +import org.junit.Rule +import org.junit.rules.TestName +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +abstract class DataConnectIntegrationTestBase { + + @get:Rule val testNameRule = TestName() + + @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + + @get:Rule val firebaseAppFactory = TestFirebaseAppFactory() + + @get:Rule val dataConnectFactory = TestDataConnectFactory(firebaseAppFactory) + + @get:Rule(order = Int.MIN_VALUE) val randomSeedTestRule = RandomSeedTestRule() + + val rs: RandomSource by randomSeedTestRule.rs + + companion object { + val testConnectorConfig: ConnectorConfig + get() = + ConnectorConfig( + connector = "demo", // TODO: change to "ctrgqyawcfbm4" once it's ready + location = "us-central1", + serviceId = "sid2ehn9ct8te", + ) + } +} + +/** The name of the currently-running test, in the form "ClassName.MethodName". */ +val DataConnectIntegrationTestBase.testName + get() = this::class.qualifiedName + "." + testNameRule.methodName + +/** + * Generates and returns a string containing random alphanumeric characters, including the name of + * the currently-running test as returned from [testName]. + * + * @param prefix A prefix to include in the returned string; if null (the default) then no prefix + * will be included. + * @param numRandomChars The number of random characters to include in the returned string; if null + * (the default) then a default number will be used. At the time of writing, the default number of + * characters is 20 (but this may change in the future). + * @return a string containing random characters and incorporating the other information identified + * above. + */ +fun DataConnectIntegrationTestBase.randomAlphanumericString( + prefix: String? = null, + numRandomChars: Int? = null +): String = buildString { + if (prefix != null) { + append(prefix) + append("_") + } + append(testName) + append("_") + append(Random.nextAlphanumericString(length = numRandomChars ?: 20)) +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAuthBackend.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAuthBackend.kt new file mode 100644 index 00000000000..c795213ac25 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAuthBackend.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth + +sealed interface FirebaseAuthBackend { + + fun getFirebaseAuth(app: FirebaseApp): FirebaseAuth = FirebaseAuth.getInstance(app) + + object Production : FirebaseAuthBackend { + override fun toString() = "FirebaseAuthBackend.Production" + } + + data class Emulator(val host: String? = null, val port: Int? = null) : FirebaseAuthBackend { + override fun toString() = "FirebaseAuthBackend.Emulator(host=$host, port=$port)" + + override fun getFirebaseAuth(app: FirebaseApp): FirebaseAuth = + super.getFirebaseAuth(app).apply { + val emulatorHost = host ?: DEFAULT_HOST + val emulatorPort = port ?: DEFAULT_PORT + useEmulator(emulatorHost, emulatorPort) + } + + companion object { + const val DEFAULT_HOST = "10.0.2.2" + const val DEFAULT_PORT = 9099 + } + } +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/InstrumentationUtils.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/InstrumentationUtils.kt new file mode 100644 index 00000000000..6effbde5e8d --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/InstrumentationUtils.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import android.os.Bundle +import androidx.test.platform.app.InstrumentationRegistry + +fun getInstrumentationArguments(): Bundle? = + try { + InstrumentationRegistry.getArguments() + } catch (_: IllegalStateException) { + // Treat IllegalStateException the same as no arguments specified, since getArguments() + // documents that it throws IllegalStateException "if no argument Bundle has been + // registered." + null + } + +fun getInstrumentationArgument(key: String): String? = getInstrumentationArguments()?.getString(key) + +class InvalidInstrumentationArgumentException( + key: String, + value: String, + details: String, + cause: Throwable? = null +) : + Exception( + "Invalid value for instrumentation argument \"$key\": " + + "\"$value\" ($details" + + (if (cause === null) "" else ": ${cause.message}") + + ")", + cause + ) diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestAppCheckProvider.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestAppCheckProvider.kt new file mode 100644 index 00000000000..ab80363a5ba --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestAppCheckProvider.kt @@ -0,0 +1,458 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.dataconnect.testutil + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.android.gms.tasks.Tasks +import com.google.firebase.FirebaseApp +import com.google.firebase.appcheck.AppCheckProvider +import com.google.firebase.appcheck.AppCheckProviderFactory +import com.google.firebase.appcheck.AppCheckToken +import com.google.firebase.util.nextAlphanumericString +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyFactory +import java.security.interfaces.RSAPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread +import kotlin.random.Random +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream + +private const val TAG = "FDCTestAppCheckProvider" + +/** + * An App Check provider that creates _real_ App Check tokens from production servers. + * + * Normally, a custom App Check provider would make an HTTP call to a server somewhere which would + * use the https://github.com/firebase/firebase-admin-node SDK to create and return an App Check + * token. However, that is somewhat inconvenient for integration tests to have this external + * dependency. So, instead, this provider has ported the logic from AppCheck.createToken() in the + * firebase-admin-node SDK to Kotlin and makes direct calls to the backend. See instuctions at + * https://firebase.google.com/docs/app-check/custom-provider for details. + * + * In order for this to work, the `google-services.json` must point to a valid project and + * `androidTestutil/src/main/assets/firebase-admin-service-account.key.json` must be a valid service + * account key created by the Google Cloud console. + */ +class DataConnectTestAppCheckProviderFactory( + private val appId: String, + private val initialToken: String? = null, +) : AppCheckProviderFactory { + + private val _tokens = MutableSharedFlow(replay = Int.MAX_VALUE) + val tokens: SharedFlow = _tokens.asSharedFlow() + + override fun create(firebaseApp: FirebaseApp): AppCheckProvider { + return DataConnectTestAppCheckProvider(firebaseApp, appId, initialToken, ::onTokenProduced) + } + + private fun onTokenProduced(token: AppCheckToken) { + check(_tokens.tryEmit(token)) { + "tryEmit() should have succeeded since _tokens is configured with replay=Int.MAX_VALUE" + + " (error code ty9kkxmqhp)" + } + } +} + +private class DataConnectTestAppCheckProvider( + firebaseApp: FirebaseApp, + private val appId: String, + private val initialToken: String?, + private val onTokenProduced: (AppCheckToken) -> Unit, +) : AppCheckProvider { + + private val applicationContext: Context = firebaseApp.applicationContext + private val projectId = requireNotNull(firebaseApp.options.projectId) + private val initialTokenUsed = AtomicBoolean(false) + + override fun getToken(): Task { + Log.d(TAG, "getToken() called") + val task = getTokenImpl() + + task.addOnCompleteListener { + if (it.isSuccessful) { + val appCheckToken = it.result + + val decodedToken = runCatching { JWT.decode(appCheckToken.token) }.getOrNull() + Log.i( + TAG, + "getToken() succeeded with" + + " token=${appCheckToken.token.toScrubbedAccessToken()}" + + " expiresAt=${decodedToken?.expiresAt}" + ) + onTokenProduced(appCheckToken) + } else { + Log.e(TAG, "getToken() failed", it.exception) + } + } + + return task + } + + private fun getTokenImpl(): Task { + if (!initialTokenUsed.getAndSet(true) && initialToken !== null) { + Log.d( + TAG, + "getToken() unconditionally returning initialToken: " + initialToken.toScrubbedAccessToken() + ) + val expireTimeMillis = Date().time + 1.hours.inWholeMilliseconds + val appCheckToken = DataConnectTestAppCheckToken(initialToken, expireTimeMillis) + return Tasks.forResult(appCheckToken) + } + + val tcs = TaskCompletionSource() + + thread(name = "DataConnectTestCustomAppCheckProvider") { + runCatching { doTokenRefresh() } + .fold( + onSuccess = { tcs.setResult(it) }, + onFailure = { tcs.setException(if (it is Exception) it else Exception(it)) } + ) + } + + return tcs.task + } + + private fun doTokenRefresh(): DataConnectTestAppCheckToken { + val account = loadServiceAccount(FIREBASE_ADMIN_SERVICE_ACCOUNT_ASSET_PATH) + val authToken = GoogleAuthTokenRetriever(account).run() + return AppCheckTokenRetriever(account, authToken, projectId, appId).run() + } + + private fun loadServiceAccount( + @Suppress("SameParameterValue") assetPath: String + ): FirebaseAdminServiceAccount { + val account = FirebaseAdminServiceAccount.fromAssetFile(applicationContext, assetPath) + if (account.projectId != projectId) { + throw ProjectIdMismatchException( + "Project ID loaded from service account file $assetPath (${account.projectId})" + + " does not match the Project ID of the FirebaseApp ($projectId)" + + " (error code axhahc4e2q)" + ) + } + return account + } + + private class ProjectIdMismatchException(message: String) : Exception(message) + + private companion object { + const val FIREBASE_ADMIN_SERVICE_ACCOUNT_ASSET_PATH = "firebase-admin-service-account.key.json" + } +} + +class DataConnectTestAppCheckToken( + private val token: String, + private val expireTimeMillis: Long, +) : AppCheckToken() { + override fun getToken(): String = token + + override fun getExpireTimeMillis(): Long = expireTimeMillis +} + +private data class GoogleAuthToken( + val accessToken: String, + val tokenType: String, + val expiresIn: Long, +) + +private data class FirebaseAdminServiceAccount( + val privateKey: RSAPrivateKey, + val projectId: String, + val clientEmail: String, +) { + + private class FirebaseAdminServiceAccountAssetFileException( + message: String, + cause: Throwable? = null + ) : Exception(message, cause) + + companion object { + private const val EXPECTED_PRIVATE_KEY_PREFIX = "-----BEGIN PRIVATE KEY-----\n" + private const val EXPECTED_PRIVATE_KEY_SUFFIX = "\n-----END PRIVATE KEY-----\n" + + private fun String.withEscapedNewlines(): String = replace("\n", "\\n") + + fun fromAssetFile(context: Context, assetPath: String): FirebaseAdminServiceAccount { + val json = Json { ignoreUnknownKeys = true } + + @Serializable + data class SerializedFirebaseAdminServiceAccount( + @SerialName("project_id") val projectId: String, + @SerialName("private_key") val privateKey: String, + @SerialName("client_email") val clientEmail: String, + ) + + val serviceAccount = + try { + context.assets.open(assetPath).use { + json.decodeFromStream(it) + } + } catch (e: Exception) { + throw FirebaseAdminServiceAccountAssetFileException( + "loading from service account asset file $assetPath failed: $e" + + " (error code kqv4a3wekv)", + e + ) + } + + val privateKeyPrefix = serviceAccount.privateKey.take(EXPECTED_PRIVATE_KEY_PREFIX.length) + if (privateKeyPrefix != EXPECTED_PRIVATE_KEY_PREFIX) { + throw FirebaseAdminServiceAccountAssetFileException( + "Invalid private key loaded from service account file $assetPath: " + + " expected it to start with ${EXPECTED_PRIVATE_KEY_PREFIX.withEscapedNewlines()} " + + " but it actually started with: " + + privateKeyPrefix.withEscapedNewlines() + + " (error code bvgfrmj7e7)" + ) + } + val privateKeySuffix = + serviceAccount.privateKey + .drop(EXPECTED_PRIVATE_KEY_PREFIX.length) + .takeLast(EXPECTED_PRIVATE_KEY_SUFFIX.length) + if (privateKeySuffix != EXPECTED_PRIVATE_KEY_SUFFIX) { + throw FirebaseAdminServiceAccountAssetFileException( + "Invalid private key loaded from service account file $assetPath: " + + " expected it to end with " + + EXPECTED_PRIVATE_KEY_SUFFIX.withEscapedNewlines() + + " but it actually ended with: " + + privateKeySuffix.withEscapedNewlines() + + " (error code hr27bmxm4h)" + ) + } + + val base64EncodedPrivateKey = + serviceAccount.privateKey + .drop(privateKeyPrefix.length) + .dropLast(privateKeySuffix.length) + .replace("\n", "") + val privateKeyBytes: ByteArray = + try { + Base64.decode(base64EncodedPrivateKey, Base64.DEFAULT) + } catch (e: Exception) { + throw FirebaseAdminServiceAccountAssetFileException( + "base64 decoding of private key in service account asset file $assetPath failed: $e" + + " (error code 45cq3mqyjx)", + e + ) + } + + val keyFactory = KeyFactory.getInstance("RSA") + val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) + val privateKey = keyFactory.generatePrivate(keySpec) + + return FirebaseAdminServiceAccount( + privateKey = privateKey as RSAPrivateKey, + projectId = serviceAccount.projectId, + clientEmail = serviceAccount.clientEmail, + ) + } + } +} + +private class AppCheckTokenRetriever( + private val account: FirebaseAdminServiceAccount, + private val authToken: GoogleAuthToken, + projectId: String, + private val appId: String +) { + + private val exchangeTokenUrl = + "https://firebaseappcheck.googleapis.com/v1/projects/$projectId/apps/$appId:exchangeCustomToken" + + fun run(): DataConnectTestAppCheckToken { + val json = Json { ignoreUnknownKeys = true } + + @Serializable data class ExchangeTokenRequest(val customToken: String) + val request = ExchangeTokenRequest(customToken = createFirebaseJavaWebToken(account)) + val requestBody = json.encodeToString(request).encodeToByteArray() + + val connection = URL(exchangeTokenUrl).openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Authorization", "Bearer ${authToken.accessToken}") + connection.setRequestProperty("Content-Type", "application/json;charset=utf-8") + connection.setRequestProperty("Content-Length", "${requestBody.size}") + connection.doOutput = true + + val requestId = "ect${Random.nextAlphanumericString(length=8)}" + Log.i( + TAG, + "[rid=$requestId]" + + " Sending exchange token refresh request at ${Date()} to ${connection.url}" + ) + connection.outputStream.use { it.write(requestBody) } + + val responseCode = connection.responseCode + Log.i(TAG, "[rid=$requestId] Got HTTP response code $responseCode") + if (responseCode != 200) { + throw AppCheckTokenRetrieverException( + "[rid=$requestId] Unexpected response code from $exchangeTokenUrl: $responseCode " + + "(error code bsywkft8rq)" + ) + } + + @Serializable data class ExchangeTokenResponse(val token: String, val ttl: String) + val response = connection.inputStream.use { json.decodeFromStream(it) } + + if (!response.ttl.endsWith("s")) { + throw AppCheckTokenRetrieverException( + "[rid=$requestId] Expected \"ttl\" in response to end with \"s\"," + + " but got: ${response.ttl} (error code c2mqk3b5an)" + ) + } + val ttlMillis = response.ttl.dropLast(1).toLong() + val expireTimeMillis = Date().time + ttlMillis + + return DataConnectTestAppCheckToken(response.token, expireTimeMillis).also { + val decodedToken = runCatching { JWT.decode(it.token) }.getOrNull() + Log.i( + TAG, + "[rid=$requestId] Exchange token refresh request succeeded with" + + " ttl=${response.ttl} expiresAt=${decodedToken?.expiresAt}" + + " token=${it.token.toScrubbedAccessToken()}" + ) + } + } + + private fun createFirebaseJavaWebToken(account: FirebaseAdminServiceAccount): String { + val algorithm: Algorithm = Algorithm.RSA256(null, account.privateKey) + + val issueTime = Date() + val expiryTime = Date(issueTime.time + 5.minutes.inWholeMilliseconds) + + return JWT.create() + .withIssuer(account.clientEmail) + .withAudience(FIREBASE_APP_CHECK_AUDIENCE) + .withIssuedAt(issueTime) + .withExpiresAt(expiryTime) + .withSubject(account.clientEmail) + .withClaim("app_id", appId) + .sign(algorithm) + } + + private class AppCheckTokenRetrieverException(message: String) : Exception(message) + + private companion object { + const val FIREBASE_APP_CHECK_AUDIENCE = + "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService" + } +} + +private class GoogleAuthTokenRetriever(private val account: FirebaseAdminServiceAccount) { + + fun run(): GoogleAuthToken { + val json = Json { ignoreUnknownKeys = true } + val token = createGoogleAuthJavaWebToken() + val requestBody = + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$token" + + val connection = + URL("https://accounts.google.com/o/oauth2/token").openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + connection.doOutput = true + + val requestId = "atr${Random.nextAlphanumericString(length=8)}" + Log.i( + TAG, + "[rid=$requestId]" + " Sending auth token refresh request at ${Date()} to ${connection.url}" + ) + connection.outputStream.use { it.write(requestBody.encodeToByteArray()) } + + val responseCode = connection.responseCode + Log.i(TAG, "[rid=$requestId] Got HTTP response code $responseCode") + if (responseCode != 200) { + throw GoogleAuthTokenRetrieverException( + "[rid=$requestId] Unexpected response code from ${connection.url}: $responseCode " + + "(error code 6dmw4wv4db)" + ) + } + + @Serializable + data class GetAuthTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Long, + @SerialName("token_type") val tokenType: String, + ) + val response = connection.inputStream.use { json.decodeFromStream(it) } + + return GoogleAuthToken( + accessToken = response.accessToken, + tokenType = response.tokenType, + expiresIn = response.expiresIn, + ) + .also { + val decodedToken = runCatching { JWT.decode(it.accessToken) }.getOrNull() + Log.i( + TAG, + "[rid=$requestId] Auth token refresh request succeeded with" + + " expiresAt=${decodedToken?.expiresAt}" + + " expires_in=${it.expiresIn} token_type=${it.tokenType}" + + " token=${it.accessToken.toScrubbedAccessToken()}" + ) + } + } + + private fun createGoogleAuthJavaWebToken(): String { + val algorithm: Algorithm = Algorithm.RSA256(null, account.privateKey) + + val issueTime = Date() + val expiryTime = Date(issueTime.time + 1.hours.inWholeMilliseconds) + + return JWT.create() + .withIssuer(account.clientEmail) + .withAudience(GOOGLE_TOKEN_AUDIENCE) + .withIssuedAt(issueTime) + .withExpiresAt(expiryTime) + .withClaim("scope", googleTokenScopes.joinToString(" ")) + .sign(algorithm) + } + + private class GoogleAuthTokenRetrieverException(message: String) : Exception(message) + + private companion object { + const val GOOGLE_TOKEN_AUDIENCE = "https://accounts.google.com/o/oauth2/token" + + val googleTokenScopes = + listOf( + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/firebase.database", + "https://www.googleapis.com/auth/firebase.messaging", + "https://www.googleapis.com/auth/identitytoolkit", + "https://www.googleapis.com/auth/userinfo.email", + ) + } +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestDataConnectFactory.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestDataConnectFactory.kt new file mode 100644 index 00000000000..b4c532518f5 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestDataConnectFactory.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.* +import com.google.firebase.util.nextAlphanumericString +import kotlin.random.Random +import kotlinx.coroutines.runBlocking + +/** + * A JUnit test rule that creates instances of [FirebaseDataConnect] for use during testing, and + * closes them upon test completion. + */ +class TestDataConnectFactory(val firebaseAppFactory: TestFirebaseAppFactory) : + FactoryTestRule() { + + fun newInstance(config: ConnectorConfig): FirebaseDataConnect = + config.run { + newInstance(Params(connector = connector, location = location, serviceId = serviceId)) + } + + fun newInstance(backend: DataConnectBackend): FirebaseDataConnect = + newInstance(Params(backend = backend)) + + fun newInstance(firebaseApp: FirebaseApp, config: ConnectorConfig): FirebaseDataConnect = + newInstance( + Params( + firebaseApp = firebaseApp, + connector = config.connector, + location = config.location, + serviceId = config.serviceId + ) + ) + + override fun createInstance(params: Params?): FirebaseDataConnect { + val instanceId = Random.nextAlphanumericString(length = 10) + + val firebaseApp = params?.firebaseApp ?: firebaseAppFactory.newInstance() + + val connectorConfig = + ConnectorConfig( + connector = params?.connector ?: "TestConnector$instanceId", + location = params?.location ?: "TestLocation$instanceId", + serviceId = params?.serviceId ?: "TestService$instanceId", + ) + + val backend = params?.backend ?: DataConnectBackend.fromInstrumentationArguments() + return backend.getDataConnect(firebaseApp, connectorConfig) + } + + override fun destroyInstance(instance: FirebaseDataConnect) { + runBlocking { instance.suspendingClose() } + } + + data class Params( + val firebaseApp: FirebaseApp? = null, + val connector: String? = null, + val location: String? = null, + val serviceId: String? = null, + val backend: DataConnectBackend? = null, + ) +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestFirebaseAppFactory.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestFirebaseAppFactory.kt new file mode 100644 index 00000000000..b291450927f --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestFirebaseAppFactory.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app +import com.google.firebase.initialize +import com.google.firebase.util.nextAlphanumericString +import kotlin.random.Random + +/** + * A JUnit test rule that creates instances of [FirebaseApp] for use during testing, and closes them + * upon test completion. + */ +class TestFirebaseAppFactory : FactoryTestRule() { + + override fun createInstance(params: Nothing?) = + Firebase.initialize( + Firebase.app.applicationContext, + Firebase.app.options, + "test-app-${Random.nextAlphanumericString(length=10)}" + ) + + override fun destroyInstance(instance: FirebaseApp) { + instance.delete() + } +} diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TurbineUtils.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TurbineUtils.kt new file mode 100644 index 00000000000..b57fc51003c --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TurbineUtils.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import app.cash.turbine.ReceiveTurbine +import javax.annotation.CheckReturnValue + +@CheckReturnValue +suspend fun ReceiveTurbine.skipItemsWhere(predicate: (T) -> Boolean): T { + while (true) { + val item = awaitItem() + if (!predicate(item)) { + return item + } + } +} diff --git a/firebase-dataconnect/androidTestutil/src/test/AndroidManifest.xml b/firebase-dataconnect/androidTestutil/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..4d68e2e4cf0 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/test/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/firebase-dataconnect/androidTestutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackendUnitTest.kt b/firebase-dataconnect/androidTestutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackendUnitTest.kt new file mode 100644 index 00000000000..452d1e963fb --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackendUnitTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.DataConnectBackend.Companion.fromInstrumentationArgument +import java.net.URI +import java.net.URL +import org.junit.Test + +class DataConnectBackendUnitTest { + + @Test + fun `fromInstrumentationArgument(null) should return Production`() { + assertThat(fromInstrumentationArgument(null)).isNull() + } + + @Test + fun `fromInstrumentationArgument('prod') should return Production`() { + assertThat(fromInstrumentationArgument("prod")).isSameInstanceAs(DataConnectBackend.Production) + } + + @Test + fun `fromInstrumentationArgument('staging') should return Staging`() { + assertThat(fromInstrumentationArgument("staging")).isSameInstanceAs(DataConnectBackend.Staging) + } + + @Test + fun `fromInstrumentationArgument('autopush') should return Autopush`() { + assertThat(fromInstrumentationArgument("autopush")) + .isSameInstanceAs(DataConnectBackend.Autopush) + } + + @Test + fun `fromInstrumentationArgument('emulator') should return Emulator()`() { + assertThat(fromInstrumentationArgument("emulator")).isEqualTo(DataConnectBackend.Emulator()) + } + + @Test + fun `fromInstrumentationArgument(emulator with host) should return Emulator() with the host`() { + assertThat(fromInstrumentationArgument("emulator:a.b.c")) + .isEqualTo(DataConnectBackend.Emulator(host = "a.b.c")) + } + + @Test + fun `fromInstrumentationArgument(emulator with port) should return Emulator() with the port`() { + assertThat(fromInstrumentationArgument("emulator::9987")) + .isEqualTo(DataConnectBackend.Emulator(port = 9987)) + } + + @Test + fun `fromInstrumentationArgument(emulator with host and port) should return Emulator() with the host and port`() { + assertThat(fromInstrumentationArgument("emulator:a.b.c:9987")) + .isEqualTo(DataConnectBackend.Emulator(host = "a.b.c", port = 9987)) + } + + @Test + fun `fromInstrumentationArgument(http url with host) should return Custom()`() { + assertThat(fromInstrumentationArgument("http://a.b.c")) + .isEqualTo(DataConnectBackend.Custom("a.b.c", false)) + } + + @Test + fun `fromInstrumentationArgument(http url with host and port) should return Custom()`() { + assertThat(fromInstrumentationArgument("http://a.b.c:9987")) + .isEqualTo(DataConnectBackend.Custom("a.b.c:9987", false)) + } + + @Test + fun `fromInstrumentationArgument(https url with host) should return Custom()`() { + assertThat(fromInstrumentationArgument("https://a.b.c")) + .isEqualTo(DataConnectBackend.Custom("a.b.c", true)) + } + + @Test + fun `fromInstrumentationArgument(https url with host and port) should return Custom()`() { + assertThat(fromInstrumentationArgument("https://a.b.c:9987")) + .isEqualTo(DataConnectBackend.Custom("a.b.c:9987", true)) + } + + @Test + fun `fromInstrumentationArgument('foo') should throw an exception`() { + val exception = + assertThrows(InvalidInstrumentationArgumentException::class) { + fromInstrumentationArgument("foo") + } + val urlParseErrorMessage = runCatching { URL("foo") }.exceptionOrNull()!!.message!! + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("foo") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("invalid", ignoreCase = true) + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("DATA_CONNECT_BACKEND") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText(urlParseErrorMessage) + } + + @Test + fun `fromInstrumentationArgument(invalid URI) should throw an exception`() { + val exception = + assertThrows(InvalidInstrumentationArgumentException::class) { + fromInstrumentationArgument("..:") + } + val uriParseErrorMessage = runCatching { URI("..:") }.exceptionOrNull()!!.message!! + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("..:") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("invalid", ignoreCase = true) + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("DATA_CONNECT_BACKEND") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText(uriParseErrorMessage) + } + + @Test + fun `fromInstrumentationArgument(invalid emulator URI) should throw an exception`() { + val exception = + assertThrows(InvalidInstrumentationArgumentException::class) { + fromInstrumentationArgument("emulator:::::") + } + val urlParseErrorMessage = runCatching { URL("https://::::") }.exceptionOrNull()!!.message!! + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("emulator:::::") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("invalid", ignoreCase = true) + assertThat(exception).hasMessageThat().containsWithNonAdjacentText("DATA_CONNECT_BACKEND") + assertThat(exception).hasMessageThat().containsWithNonAdjacentText(urlParseErrorMessage) + } +} diff --git a/firebase-dataconnect/api.txt b/firebase-dataconnect/api.txt new file mode 100644 index 00000000000..3e7d18e11d0 --- /dev/null +++ b/firebase-dataconnect/api.txt @@ -0,0 +1,293 @@ +// Signature format: 2.0 +package com.google.firebase.dataconnect { + + @kotlinx.serialization.Serializable(with=AnyValueSerializer::class) public final class AnyValue { + ctor public AnyValue(@NonNull java.util.Map value); + ctor public AnyValue(@NonNull java.util.List value); + ctor public AnyValue(@NonNull String value); + ctor public AnyValue(boolean value); + ctor public AnyValue(double value); + method @NonNull public Object getValue(); + property @NonNull public final Object value; + field @NonNull public static final com.google.firebase.dataconnect.AnyValue.Companion Companion; + } + + public static final class AnyValue.Companion { + } + + public final class AnyValueKt { + method public static T decode(@NonNull com.google.firebase.dataconnect.AnyValue, @NonNull kotlinx.serialization.DeserializationStrategy deserializer, @Nullable kotlinx.serialization.modules.SerializersModule serializersModule = null); + method public static inline T decode(@NonNull com.google.firebase.dataconnect.AnyValue); + method @NonNull public static com.google.firebase.dataconnect.AnyValue encode(@NonNull com.google.firebase.dataconnect.AnyValue.Companion, @Nullable T value, @NonNull kotlinx.serialization.SerializationStrategy serializer, @Nullable kotlinx.serialization.modules.SerializersModule serializersModule = null); + method public static inline com.google.firebase.dataconnect.AnyValue encode(@NonNull com.google.firebase.dataconnect.AnyValue.Companion, @Nullable T value); + method @NonNull public static com.google.firebase.dataconnect.AnyValue fromAny(@NonNull com.google.firebase.dataconnect.AnyValue.Companion, @NonNull Object value); + method @Nullable public static com.google.firebase.dataconnect.AnyValue fromNullableAny(@NonNull com.google.firebase.dataconnect.AnyValue.Companion, @Nullable Object value); + } + + public final class ConnectorConfig { + ctor public ConnectorConfig(@NonNull String connector, @NonNull String location, @NonNull String serviceId); + method @NonNull public String getConnector(); + method @NonNull public String getLocation(); + method @NonNull public String getServiceId(); + property @NonNull public final String connector; + property @NonNull public final String location; + property @NonNull public final String serviceId; + } + + public final class ConnectorConfigKt { + method @NonNull public static com.google.firebase.dataconnect.ConnectorConfig copy(@NonNull com.google.firebase.dataconnect.ConnectorConfig, @NonNull String connector = connector, @NonNull String location = location, @NonNull String serviceId = serviceId); + } + + public class DataConnectException extends java.lang.Exception { + ctor public DataConnectException(@NonNull String message, @Nullable Throwable cause = null); + } + + public final class DataConnectSettings { + ctor public DataConnectSettings(@NonNull String host = "firebasedataconnect.googleapis.com", boolean sslEnabled = true); + method @NonNull public String getHost(); + method public boolean getSslEnabled(); + property @NonNull public final String host; + property public final boolean sslEnabled; + } + + public final class DataConnectSettingsKt { + method @NonNull public static com.google.firebase.dataconnect.DataConnectSettings copy(@NonNull com.google.firebase.dataconnect.DataConnectSettings, @NonNull String host = host, boolean sslEnabled = sslEnabled); + } + + public interface FirebaseDataConnect extends java.lang.AutoCloseable { + method public void close(); + method public boolean equals(@Nullable Object other); + method @NonNull public com.google.firebase.FirebaseApp getApp(); + method @NonNull public com.google.firebase.dataconnect.ConnectorConfig getConfig(); + method @NonNull public com.google.firebase.dataconnect.DataConnectSettings getSettings(); + method public int hashCode(); + method @NonNull public com.google.firebase.dataconnect.MutationRef mutation(@NonNull String operationName, @Nullable Variables variables, @NonNull kotlinx.serialization.DeserializationStrategy dataDeserializer, @NonNull kotlinx.serialization.SerializationStrategy variablesSerializer, @Nullable kotlin.jvm.functions.Function1,kotlin.Unit> optionsBuilder = null); + method @NonNull public com.google.firebase.dataconnect.QueryRef query(@NonNull String operationName, @Nullable Variables variables, @NonNull kotlinx.serialization.DeserializationStrategy dataDeserializer, @NonNull kotlinx.serialization.SerializationStrategy variablesSerializer, @Nullable kotlin.jvm.functions.Function1,kotlin.Unit> optionsBuilder = null); + method @Nullable public suspend Object suspendingClose(@NonNull kotlin.coroutines.Continuation); + method @NonNull public String toString(); + method public void useEmulator(@NonNull String host = "10.0.2.2", int port = 9399); + property @NonNull public abstract com.google.firebase.FirebaseApp app; + property @NonNull public abstract com.google.firebase.dataconnect.ConnectorConfig config; + property @NonNull public abstract com.google.firebase.dataconnect.DataConnectSettings settings; + field @NonNull public static final com.google.firebase.dataconnect.FirebaseDataConnect.Companion Companion; + } + + public enum FirebaseDataConnect.CallerSdkType { + method @NonNull public static com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType valueOf(@NonNull String name) throws java.lang.IllegalArgumentException; + method @NonNull public static com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType[] values(); + enum_constant public static final com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType Base; + enum_constant public static final com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType Generated; + } + + public static final class FirebaseDataConnect.Companion { + } + + public static interface FirebaseDataConnect.MutationRefOptionsBuilder { + method @Nullable public com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType getCallerSdkType(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getDataSerializersModule(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getVariablesSerializersModule(); + method public void setCallerSdkType(@Nullable com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType); + method public void setDataSerializersModule(@Nullable kotlinx.serialization.modules.SerializersModule); + method public void setVariablesSerializersModule(@Nullable kotlinx.serialization.modules.SerializersModule); + property @Nullable public abstract com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType callerSdkType; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule dataSerializersModule; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule variablesSerializersModule; + } + + public static interface FirebaseDataConnect.QueryRefOptionsBuilder { + method @Nullable public com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType getCallerSdkType(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getDataSerializersModule(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getVariablesSerializersModule(); + method public void setCallerSdkType(@Nullable com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType); + method public void setDataSerializersModule(@Nullable kotlinx.serialization.modules.SerializersModule); + method public void setVariablesSerializersModule(@Nullable kotlinx.serialization.modules.SerializersModule); + property @Nullable public abstract com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType callerSdkType; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule dataSerializersModule; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule variablesSerializersModule; + } + + public final class FirebaseDataConnectKt { + method @NonNull public static com.google.firebase.dataconnect.FirebaseDataConnect getInstance(@NonNull com.google.firebase.dataconnect.FirebaseDataConnect.Companion, @NonNull com.google.firebase.FirebaseApp app, @NonNull com.google.firebase.dataconnect.ConnectorConfig config, @NonNull com.google.firebase.dataconnect.DataConnectSettings settings = com.google.firebase.dataconnect.DataConnectSettings()); + method @NonNull public static com.google.firebase.dataconnect.FirebaseDataConnect getInstance(@NonNull com.google.firebase.dataconnect.FirebaseDataConnect.Companion, @NonNull com.google.firebase.dataconnect.ConnectorConfig config, @NonNull com.google.firebase.dataconnect.DataConnectSettings settings = com.google.firebase.dataconnect.DataConnectSettings()); + method @NonNull public static com.google.firebase.dataconnect.LogLevel getLogLevel(@NonNull com.google.firebase.dataconnect.FirebaseDataConnect.Companion); + method public static void setLogLevel(@NonNull com.google.firebase.dataconnect.FirebaseDataConnect.Companion, @NonNull com.google.firebase.dataconnect.LogLevel); + } + + public enum LogLevel { + method @NonNull public static com.google.firebase.dataconnect.LogLevel valueOf(@NonNull String name) throws java.lang.IllegalArgumentException; + method @NonNull public static com.google.firebase.dataconnect.LogLevel[] values(); + enum_constant public static final com.google.firebase.dataconnect.LogLevel DEBUG; + enum_constant public static final com.google.firebase.dataconnect.LogLevel NONE; + enum_constant public static final com.google.firebase.dataconnect.LogLevel WARN; + } + + public interface MutationRef extends com.google.firebase.dataconnect.OperationRef { + method @Nullable public suspend Object execute(@NonNull kotlin.coroutines.Continuation>); + } + + public interface MutationResult extends com.google.firebase.dataconnect.OperationResult { + method @NonNull public com.google.firebase.dataconnect.MutationRef getRef(); + property @NonNull public abstract com.google.firebase.dataconnect.MutationRef ref; + } + + public interface OperationRef { + method public boolean equals(@Nullable Object other); + method @Nullable public suspend Object execute(@NonNull kotlin.coroutines.Continuation>); + method @NonNull public com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType getCallerSdkType(); + method @NonNull public com.google.firebase.dataconnect.FirebaseDataConnect getDataConnect(); + method @NonNull public kotlinx.serialization.DeserializationStrategy getDataDeserializer(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getDataSerializersModule(); + method @NonNull public String getOperationName(); + method public Variables getVariables(); + method @NonNull public kotlinx.serialization.SerializationStrategy getVariablesSerializer(); + method @Nullable public kotlinx.serialization.modules.SerializersModule getVariablesSerializersModule(); + method public int hashCode(); + method @NonNull public String toString(); + property @NonNull public abstract com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType callerSdkType; + property @NonNull public abstract com.google.firebase.dataconnect.FirebaseDataConnect dataConnect; + property @NonNull public abstract kotlinx.serialization.DeserializationStrategy dataDeserializer; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule dataSerializersModule; + property @NonNull public abstract String operationName; + property public abstract Variables variables; + property @NonNull public abstract kotlinx.serialization.SerializationStrategy variablesSerializer; + property @Nullable public abstract kotlinx.serialization.modules.SerializersModule variablesSerializersModule; + } + + public interface OperationResult { + method public boolean equals(@Nullable Object other); + method public Data getData(); + method @NonNull public com.google.firebase.dataconnect.OperationRef getRef(); + method public int hashCode(); + method @NonNull public String toString(); + property public abstract Data data; + property @NonNull public abstract com.google.firebase.dataconnect.OperationRef ref; + } + + @kotlinx.serialization.Serializable(with=OptionalVariable.Serializer::class) public sealed interface OptionalVariable { + method @Nullable public T valueOrNull(); + method public T valueOrThrow(); + } + + public static final class OptionalVariable.Serializer implements kotlinx.serialization.KSerializer> { + ctor public OptionalVariable.Serializer(@NonNull kotlinx.serialization.KSerializer elementSerializer); + method @NonNull public com.google.firebase.dataconnect.OptionalVariable deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); + method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); + method public void serialize(@NonNull kotlinx.serialization.encoding.Encoder encoder, @NonNull com.google.firebase.dataconnect.OptionalVariable value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + } + + public static final class OptionalVariable.Undefined implements com.google.firebase.dataconnect.OptionalVariable { + method @Nullable public Void valueOrNull(); + method @NonNull public Void valueOrThrow(); + field @NonNull public static final com.google.firebase.dataconnect.OptionalVariable.Undefined INSTANCE; + } + + public static final class OptionalVariable.Value implements com.google.firebase.dataconnect.OptionalVariable { + ctor public OptionalVariable.Value(@Nullable T value); + method public T getValue(); + method public T valueOrNull(); + method public T valueOrThrow(); + property public final T value; + } + + public interface QueryRef extends com.google.firebase.dataconnect.OperationRef { + method @Nullable public suspend Object execute(@NonNull kotlin.coroutines.Continuation>); + method @NonNull public com.google.firebase.dataconnect.QuerySubscription subscribe(); + } + + public interface QueryResult extends com.google.firebase.dataconnect.OperationResult { + method @NonNull public com.google.firebase.dataconnect.QueryRef getRef(); + property @NonNull public abstract com.google.firebase.dataconnect.QueryRef ref; + } + + public interface QuerySubscription { + method public boolean equals(@Nullable Object other); + method @NonNull public kotlinx.coroutines.flow.Flow> getFlow(); + method @NonNull public com.google.firebase.dataconnect.QueryRef getQuery(); + method public int hashCode(); + method @NonNull public String toString(); + property @NonNull public abstract kotlinx.coroutines.flow.Flow> flow; + property @NonNull public abstract com.google.firebase.dataconnect.QueryRef query; + } + + public interface QuerySubscriptionResult { + method public boolean equals(@Nullable Object other); + method @NonNull public com.google.firebase.dataconnect.QueryRef getQuery(); + method @NonNull public Object getResult(); + method public int hashCode(); + method @NonNull public String toString(); + property @NonNull public abstract com.google.firebase.dataconnect.QueryRef query; + property @NonNull public abstract Object result; + } + +} + +package com.google.firebase.dataconnect.generated { + + public interface GeneratedConnector { + method public boolean equals(@Nullable Object other); + method @NonNull public com.google.firebase.dataconnect.FirebaseDataConnect getDataConnect(); + method public int hashCode(); + method @NonNull public String toString(); + property @NonNull public abstract com.google.firebase.dataconnect.FirebaseDataConnect dataConnect; + } + + public interface GeneratedMutation extends com.google.firebase.dataconnect.generated.GeneratedOperation { + method @NonNull public default com.google.firebase.dataconnect.MutationRef ref(@Nullable Variables variables); + } + + public interface GeneratedOperation { + method @NonNull public Connector getConnector(); + method @NonNull public kotlinx.serialization.DeserializationStrategy getDataDeserializer(); + method @NonNull public String getOperationName(); + method @NonNull public kotlinx.serialization.SerializationStrategy getVariablesSerializer(); + method @NonNull public default com.google.firebase.dataconnect.OperationRef ref(@Nullable Variables variables); + method @NonNull public String toString(); + property @NonNull public abstract Connector connector; + property @NonNull public abstract kotlinx.serialization.DeserializationStrategy dataDeserializer; + property @NonNull public abstract String operationName; + property @NonNull public abstract kotlinx.serialization.SerializationStrategy variablesSerializer; + } + + public interface GeneratedQuery extends com.google.firebase.dataconnect.generated.GeneratedOperation { + method @NonNull public default com.google.firebase.dataconnect.QueryRef ref(@Nullable Variables variables); + } + +} + +package com.google.firebase.dataconnect.serializers { + + public final class AnyValueSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public com.google.firebase.dataconnect.AnyValue deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); + method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); + method public void serialize(@NonNull kotlinx.serialization.encoding.Encoder encoder, @NonNull com.google.firebase.dataconnect.AnyValue value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.AnyValueSerializer INSTANCE; + } + + public final class DateSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public java.util.Date deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); + method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); + method public void serialize(@NonNull kotlinx.serialization.encoding.Encoder encoder, @NonNull java.util.Date value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.DateSerializer INSTANCE; + } + + public final class TimestampSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public com.google.firebase.Timestamp deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); + method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); + method public void serialize(@NonNull kotlinx.serialization.encoding.Encoder encoder, @NonNull com.google.firebase.Timestamp value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.TimestampSerializer INSTANCE; + } + + public final class UUIDSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public java.util.UUID deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); + method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); + method public void serialize(@NonNull kotlinx.serialization.encoding.Encoder encoder, @NonNull java.util.UUID value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.UUIDSerializer INSTANCE; + } + +} + diff --git a/firebase-dataconnect/connectors/connectors.gradle.kts b/firebase-dataconnect/connectors/connectors.gradle.kts new file mode 100644 index 00000000000..358f8227888 --- /dev/null +++ b/firebase-dataconnect/connectors/connectors.gradle.kts @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.kotlinx.serialization) + id("com.google.firebase.dataconnect.gradle.plugin") +} + +android { + val compileSdkVersion : Int by rootProject + val targetSdkVersion : Int by rootProject + val minSdkVersion : Int by rootProject + + namespace = "com.google.firebase.dataconnect.connectors" + compileSdk = compileSdkVersion + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + + @Suppress("UnstableApiUsage") + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + + packaging { + resources { + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") + } + } + + dataconnect { + configDir = file("../emulator/dataconnect") + codegen { + connectors = listOf("demo", "keywords") + } + } +} + +dependencies { + implementation(project(":firebase-dataconnect")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + + testImplementation(project(":firebase-dataconnect:testutil")) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.truth) + + androidTestImplementation(project(":firebase-dataconnect:androidTestutil")) + androidTestImplementation(project(":firebase-dataconnect:testutil")) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotest.assertions) + androidTestImplementation(libs.kotest.property) + androidTestImplementation(libs.kotlin.coroutines.test) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.truth.liteproto.extension) + androidTestImplementation(libs.turbine) +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} + +// Enable Kotlin "Explicit API Mode". This causes the Kotlin compiler to fail if any +// classes, methods, or properties have implicit `public` visibility. This check helps +// avoid accidentally leaking elements into the public API, requiring that any public +// element be explicitly declared as `public`. +// https://github.com/Kotlin/KEEP/blob/master/proposals/explicit-api-mode.md +// https://chao2zhang.medium.com/explicit-api-mode-for-kotlin-on-android-b8264fdd76d1 +tasks.withType().all { + if (!name.contains("test", ignoreCase = true)) { + if (!kotlinOptions.freeCompilerArgs.contains("-Xexplicit-api=strict")) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorIntegrationTest.kt new file mode 100644 index 00000000000..3f274896358 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorIntegrationTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.Firebase +import com.google.firebase.app +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.randomAlphanumericString +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.* +import org.junit.Test + +class PostsConnectorIntegrationTest : DataConnectIntegrationTestBase() { + + private val posts: PostsConnector by lazy { + val firebaseApp = firebaseAppFactory.newInstance() + val dataConnect = dataConnectFactory.newInstance(firebaseApp, PostsConnector.config) + PostsConnector.getInstance(firebaseApp, dataConnect.settings).also { + require(it.dataConnect === dataConnect) + } + } + + @Test + fun instance_ShouldBeAssociatedWithTheDefaultFirebaseApp() { + val posts = PostsConnector.instance + cleanupAfterTest(posts) + + assertThat(posts.dataConnect.app).isSameInstanceAs(Firebase.app) + } + + @Test + fun instance_ShouldAlwaysReturnTheSameObject() { + val posts1 = PostsConnector.instance + cleanupAfterTest(posts1) + val posts2 = PostsConnector.instance + cleanupAfterTest(posts2) + val posts3 = PostsConnector.instance + cleanupAfterTest(posts3) + + assertThat(posts1).isSameInstanceAs(posts2) + assertThat(posts1).isSameInstanceAs(posts3) + } + + @Test + fun instance_ShouldReturnANewInstanceIfTheDataConnectIsClosed() { + val posts1 = PostsConnector.instance + posts1.dataConnect.close() + val posts2 = PostsConnector.instance + cleanupAfterTest(posts2) + + assertThat(posts1).isNotSameInstanceAs(posts2) + assertThat(posts1.dataConnect).isNotSameInstanceAs(posts2.dataConnect) + assertThat(posts1.dataConnect.app).isSameInstanceAs(posts2.dataConnect.app) + } + + @Test + fun getInstance_FirebaseApp_ShouldBeAssociatedWithTheGivenFirebaseApp() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + + val posts1 = PostsConnector.getInstance(app1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2) + cleanupAfterTest(posts2) + + assertThat(posts1.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2.dataConnect.app).isSameInstanceAs(app2) + } + + @Test + fun getInstance_FirebaseApp_ShouldAlwaysReturnTheSameObjectForAGivenFirebaseApp() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + + val posts1 = PostsConnector.getInstance(app1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2) + cleanupAfterTest(posts2) + val posts1b = PostsConnector.getInstance(app1) + cleanupAfterTest(posts1b) + val posts2b = PostsConnector.getInstance(app2) + cleanupAfterTest(posts2b) + + assertThat(posts1).isSameInstanceAs(posts1b) + assertThat(posts2).isSameInstanceAs(posts2b) + } + + @Test + fun getInstance_FirebaseApp_ShouldReturnANewInstanceIfTheDataConnectIsClosed() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + + val posts1 = PostsConnector.getInstance(app1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2) + cleanupAfterTest(posts2) + posts1.dataConnect.close() + posts2.dataConnect.close() + val posts1b = PostsConnector.getInstance(app1) + cleanupAfterTest(posts1b) + val posts2b = PostsConnector.getInstance(app2) + cleanupAfterTest(posts2b) + + assertThat(posts1).isNotSameInstanceAs(posts1b) + assertThat(posts2).isNotSameInstanceAs(posts2b) + assertThat(posts1.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2.dataConnect.app).isSameInstanceAs(app2) + assertThat(posts1b.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2b.dataConnect.app).isSameInstanceAs(app2) + } + + @Test + fun getInstance_DataConnectSettings_ShouldBeAssociatedWithTheDefaultFirebaseAppAndGivenSettings() { + // Clear the default `FirebaseDataConnect` instance in case it already exists with different + // settings, which would cause the calls to `getInstance()` below to unexpectedly throw. + PostsConnector.instance.dataConnect.close() + val settings = randomDataConnectSettings() + + val posts = PostsConnector.getInstance(settings) + cleanupAfterTest(posts) + + assertThat(posts.dataConnect.app).isSameInstanceAs(Firebase.app) + assertThat(posts.dataConnect.settings).isSameInstanceAs(settings) + } + + @Test + fun getInstance_DataConnectSettings_ShouldAlwaysReturnTheSameObject() { + // Clear the default `FirebaseDataConnect` instance in case it already exists with different + // settings, which would cause the calls to `getInstance()` below to unexpectedly throw. + PostsConnector.instance.dataConnect.close() + val settings = randomDataConnectSettings() + + val posts1 = PostsConnector.getInstance(settings) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(settings) + cleanupAfterTest(posts2) + + assertThat(posts1).isSameInstanceAs(posts2) + } + + @Test + fun getInstance_DataConnectSettings_ShouldReturnANewInstanceIfTheDataConnectIsClosed() { + // Clear the default `FirebaseDataConnect` instance in case it already exists with different + // settings, which would cause the calls to `getInstance()` below to unexpectedly throw. + PostsConnector.instance.dataConnect.close() + val settings = randomDataConnectSettings() + + val posts1 = PostsConnector.getInstance(settings) + cleanupAfterTest(posts1) + posts1.dataConnect.close() + val posts2 = PostsConnector.getInstance(settings) + cleanupAfterTest(posts2) + + assertThat(posts1).isNotSameInstanceAs(posts2) + assertThat(posts1.dataConnect.app).isSameInstanceAs(Firebase.app) + } + + @Test + fun getInstance_FirebaseApp_DataConnectSettings_ShouldBeAssociatedWithTheGivenFirebaseApp() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + val settings1 = randomDataConnectSettings() + val settings2 = randomDataConnectSettings() + + val posts1 = PostsConnector.getInstance(app1, settings1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2, settings2) + cleanupAfterTest(posts2) + + assertThat(posts1.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2.dataConnect.app).isSameInstanceAs(app2) + assertThat(posts1.dataConnect.settings).isSameInstanceAs(settings1) + assertThat(posts2.dataConnect.settings).isSameInstanceAs(settings2) + } + + @Test + fun getInstance_FirebaseApp_DataConnectSettings_ShouldAlwaysReturnTheSameObjectForAGivenFirebaseApp() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + val settings1 = randomDataConnectSettings() + val settings2 = randomDataConnectSettings() + + val posts1 = PostsConnector.getInstance(app1, settings1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2, settings2) + cleanupAfterTest(posts2) + val posts1b = PostsConnector.getInstance(app1, settings1) + cleanupAfterTest(posts1b) + val posts2b = PostsConnector.getInstance(app2, settings2) + cleanupAfterTest(posts2b) + + assertThat(posts1).isSameInstanceAs(posts1b) + assertThat(posts2).isSameInstanceAs(posts2b) + assertThat(posts1.dataConnect.settings).isSameInstanceAs(settings1) + assertThat(posts2.dataConnect.settings).isSameInstanceAs(settings2) + } + + @Test + fun getInstance_FirebaseApp_DataConnectSettings_ShouldReturnANewInstanceIfTheDataConnectIsClosed() { + val app1 = firebaseAppFactory.newInstance() + val app2 = firebaseAppFactory.newInstance() + val settings1 = randomDataConnectSettings() + val settings2 = randomDataConnectSettings() + + val posts1 = PostsConnector.getInstance(app1, settings1) + cleanupAfterTest(posts1) + val posts2 = PostsConnector.getInstance(app2, settings2) + cleanupAfterTest(posts2) + posts1.dataConnect.close() + posts2.dataConnect.close() + val posts1b = PostsConnector.getInstance(app1, settings1) + cleanupAfterTest(posts1b) + val posts2b = PostsConnector.getInstance(app2, settings2) + cleanupAfterTest(posts2b) + + assertThat(posts1).isNotSameInstanceAs(posts1b) + assertThat(posts2).isNotSameInstanceAs(posts2b) + assertThat(posts1.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2.dataConnect.app).isSameInstanceAs(app2) + assertThat(posts1b.dataConnect.app).isSameInstanceAs(app1) + assertThat(posts2b.dataConnect.app).isSameInstanceAs(app2) + } + + @Test + fun createCommentShouldAddACommentToThePost() = runTest { + val postId = randomPostId() + val postContent = randomPostContent() + posts.createPost(id = postId, content = postContent) + + val comment1Id = randomCommentId() + val comment1Content = randomPostContent() + posts.createComment(id = comment1Id, content = comment1Content, postId = postId) + + val comment2Id = randomCommentId() + val comment2Content = randomPostContent() + posts.createComment(id = comment2Id, content = comment2Content, postId = postId) + + val queryResponse = posts.getPost(id = postId) + assertWithMessage("queryResponse") + .that(queryResponse.data.post) + .isEqualTo( + GetPost.Data.Post( + content = postContent, + comments = + listOf( + GetPost.Data.Post.Comment(id = comment1Id, content = comment1Content), + GetPost.Data.Post.Comment(id = comment2Id, content = comment2Content), + ) + ) + ) + } + + @Test + fun getPostWithNonExistingId() = runTest { + val queryResponse = posts.getPost(id = randomPostId()) + assertWithMessage("queryResponse").that(queryResponse.data.post).isNull() + } + + @Test + fun createPostThenGetPost() = runTest { + val postId = randomPostId() + val postContent = randomPostContent() + + posts.createPost(id = postId, content = postContent) + + val queryResponse = posts.getPost(id = postId) + assertWithMessage("queryResponse") + .that(queryResponse.data.post) + .isEqualTo(GetPost.Data.Post(content = postContent, comments = emptyList())) + } + + @Test + fun subscribe() = runTest { + val postId = randomPostId() + val postContent = randomPostContent() + + posts.createPost(id = postId, content = postContent) + + val querySubscription = posts.getPost.ref(id = postId).subscribe() + val result = querySubscription.flow.first() + assertWithMessage("result1.post.content") + .that(result.result.getOrThrow().data.post?.content) + .isEqualTo(postContent) + } + + /** + * Ensures that the [FirebaseDataConnect] instance encapsulated by the given [PostsConnector] is + * closed when this test completes. This method should be called immediately after all calls of + * [PostsConnector.getInstance] and [PostsConnector.instance] to ensure that the instance doesn't + * leak into other tests. + */ + private fun cleanupAfterTest(connector: PostsConnector) { + dataConnectFactory.adoptInstance(connector.dataConnect) + } + + private fun randomPostId() = randomAlphanumericString(prefix = "PostId") + private fun randomPostContent() = randomAlphanumericString("PostContent") + private fun randomCommentId() = randomAlphanumericString("CommentId") + private fun randomHost() = randomAlphanumericString("Host") + private fun randomDataConnectSettings() = DataConnectSettings(host = randomHost()) +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/AnyScalarIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/AnyScalarIntegrationTest.kt new file mode 100644 index 00000000000..4c38d1b0db4 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/AnyScalarIntegrationTest.kt @@ -0,0 +1,1033 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.firebase.dataconnect.AnyValue +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.OperationRef +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.fromAny +import com.google.firebase.dataconnect.generated.GeneratedMutation +import com.google.firebase.dataconnect.generated.GeneratedQuery +import com.google.firebase.dataconnect.testutil.EdgeCases +import com.google.firebase.dataconnect.testutil.anyListScalar +import com.google.firebase.dataconnect.testutil.anyScalar +import com.google.firebase.dataconnect.testutil.expectedAnyScalarRoundTripValue +import com.google.firebase.dataconnect.testutil.filterNotAnyScalarMatching +import com.google.firebase.dataconnect.testutil.filterNotIncludesAllMatchingAnyScalars +import com.google.firebase.dataconnect.testutil.filterNotNull +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContainIgnoringCase +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.orNull +import io.kotest.property.checkAll +import java.util.UUID +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Test + +class AnyScalarIntegrationTest : DemoConnectorIntegrationTestBase() { + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullable @table { value: Any!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars.filterNotNull()) { + withClue("value=$value") { verifyAnyScalarNonNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars.filterNotNull()) { + val otherValues = Arb.anyScalar().filterNotNull().filterNotAnyScalarMatching(value) + withClue("value=$value otherValues=$otherValues") { + verifyAnyScalarNonNullableQueryVariable(value, otherValues.next(), otherValues.next()) + } + } + } + } + + @Test + fun anyScalarNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar().filterNotNull()) { value -> + verifyAnyScalarNonNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar().filterNotNull()) { value -> + val otherValues = Arb.anyScalar().filterNotNull().filterNotAnyScalarMatching(value) + verifyAnyScalarNonNullableQueryVariable(value, otherValues.next(), otherValues.next()) + } + } + + @Test + fun anyScalarNonNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableInsert.verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableGetAllByTagAndValue.verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullable_MutationFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableInsert.verifyFailsWithNullVariableValue() + } + + @Test + fun anyScalarNonNullable_QueryFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableGetAllByTagAndValue.verifyFailsWithNullVariableValue() + } + + private suspend fun verifyAnyScalarNonNullableRoundTrip(value: Any) { + val anyValue = AnyValue.fromAny(value) + val expectedQueryResult = AnyValue.fromAny(expectedAnyScalarRoundTripValue(value)) + val key = connector.anyScalarNonNullableInsert.execute(anyValue) {}.data.key + + val queryResult = connector.anyScalarNonNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNonNullableGetByKeyQuery.Data( + AnyScalarNonNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNonNullableQueryVariable( + value: Any, + value2: Any, + value3: Any, + ) { + require(value != value2) + require(value != value3) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value2)) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value3)) + + val tag = UUID.randomUUID().toString() + val anyValue = AnyValue.fromAny(value) + val anyValue2 = AnyValue.fromAny(value2) + val anyValue3 = AnyValue.fromAny(value3) + val keys = + connector.anyScalarNonNullableInsert3 + .execute(anyValue, anyValue2, anyValue3) { this.tag = tag } + .data + + val queryResult = + connector.anyScalarNonNullableGetAllByTagAndValue.execute(anyValue) { this.tag = tag } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullable @table { value: Any, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars) { + withClue("value=$value") { verifyAnyScalarNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars) { + val otherValues = Arb.anyScalar().filterNotAnyScalarMatching(value) + withClue("value=$value otherValues=$otherValues") { + verifyAnyScalarNullableQueryVariable(value, otherValues.next(), otherValues.next()) + } + } + } + } + + @Test + fun anyScalarNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar()) { value -> + verifyAnyScalarNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar()) { value -> + val otherValues = Arb.anyScalar().filterNotAnyScalarMatching(value) + verifyAnyScalarNullableQueryVariable(value, otherValues.next(), otherValues.next()) + } + } + + @Test + fun anyScalarNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + val key = connector.anyScalarNullableInsert.execute {}.data.key + val queryResult = connector.anyScalarNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + val values = Arb.anyScalar().map { AnyValue.fromAny(it) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableInsert3 + .execute { + this.tag = tag + this.value1 = values.next() + this.value2 = values.next() + this.value3 = values.next() + } + .data + val keyIds = listOf(keys.key1, keys.key2, keys.key3).map { it.id } + + val queryResult = connector.anyScalarNullableGetAllByTagAndValue.execute { this.tag = tag } + val queryIds = queryResult.data.items.map { it.id } + queryIds shouldContainExactlyInAnyOrder keyIds + } + + @Test + fun anyScalarNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + val key = connector.anyScalarNullableInsert.execute { value = null }.data.key + val queryResult = connector.anyScalarNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + val values = Arb.anyScalar().filter { it !== null }.map { AnyValue.fromAny(it) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableInsert3 + .execute { + this.tag = tag + this.value1 = null + this.value2 = values.next() + this.value3 = values.next() + } + .data + + val queryResult = + connector.anyScalarNullableGetAllByTagAndValue.execute { + this.tag = tag + this.value = null + } + + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + private suspend fun verifyAnyScalarNullableRoundTrip(value: Any?) { + val anyValue = AnyValue.fromAny(value) + val expectedQueryResult = AnyValue.fromAny(expectedAnyScalarRoundTripValue(value)) + val key = connector.anyScalarNullableInsert.execute { this.value = anyValue }.data.key + + val queryResult = connector.anyScalarNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNullableGetByKeyQuery.Data( + AnyScalarNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNullableQueryVariable( + value: Any?, + value2: Any?, + value3: Any? + ) { + require(value != value2) + require(value != value3) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value2)) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value3)) + + val tag = UUID.randomUUID().toString() + val anyValue = AnyValue.fromAny(value) + val anyValue2 = AnyValue.fromAny(value2) + val anyValue3 = AnyValue.fromAny(value3) + val keys = + connector.anyScalarNullableInsert3 + .execute { + this.tag = tag + this.value1 = anyValue + this.value2 = anyValue2 + this.value3 = anyValue3 + } + .data + + val queryResult = + connector.anyScalarNullableGetAllByTagAndValue.execute { + this.value = anyValue + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullableListOfNullable @table { value: [Any], tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullableListOfNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { verifyAnyScalarNullableListOfNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNullableListOfNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + withClue("value=$value") { + verifyAnyScalarNullableListOfNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + } + } + + @Test + fun anyScalarNullableListOfNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarNullableListOfNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNullableListOfNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + verifyAnyScalarNullableListOfNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + + @Test + fun anyScalarNullableListOfNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + val key = connector.anyScalarNullableListOfNullableInsert.execute {}.data.key + val queryResult = connector.anyScalarNullableListOfNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableListOfNullableInsert3 + .execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + .data + val keyIds = listOf(keys.key1, keys.key2, keys.key3).map { it.id } + + val queryResult = + connector.anyScalarNullableListOfNullableGetAllByTagAndValue.execute { this.tag = tag } + val queryIds = queryResult.data.items.map { it.id } + queryIds shouldContainExactlyInAnyOrder keyIds + } + + @Test + fun anyScalarNullableListOfNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + val key = connector.anyScalarNullableListOfNullableInsert.execute { value = null }.data.key + val queryResult = connector.anyScalarNullableListOfNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + connector.anyScalarNullableListOfNullableInsert3.execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + + val queryResult = + connector.anyScalarNullableListOfNullableGetAllByTagAndValue.execute { + this.tag = tag + this.value = null + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldBeEmpty() + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableListOfNullableInsert3 + .execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + .data + + val queryResult = + connector.anyScalarNullableListOfNullableGetAllByTagAndValue.execute { + this.tag = tag + this.value = emptyList() + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key2.id, keys.key3.id) + } + + private suspend fun verifyAnyScalarNullableListOfNullableRoundTrip(value: List?) { + val anyValue = value?.map { AnyValue.fromAny(it) } + val expectedQueryResult = value?.map { AnyValue.fromAny(expectedAnyScalarRoundTripValue(it)) } + val key = + connector.anyScalarNullableListOfNullableInsert.execute { this.value = anyValue }.data.key + + val queryResult = connector.anyScalarNullableListOfNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNullableListOfNullableGetByKeyQuery.Data( + AnyScalarNullableListOfNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNullableListOfNullableQueryVariable( + value: List?, + value2: List?, + value3: List?, + ) { + require(value != value2) + require(value != value3) + // TODO: implement a check to ensure that value is not a subset of value2 and value3. + + val tag = UUID.randomUUID().toString() + val anyValue = value?.map(AnyValue::fromAny) + val anyValue2 = value2?.map(AnyValue::fromAny) + val anyValue3 = value3?.map(AnyValue::fromAny) + val keys = + connector.anyScalarNullableListOfNullableInsert3 + .execute { + this.tag = tag + this.value1 = anyValue + this.value2 = anyValue2 + this.value3 = anyValue3 + } + .data + + val queryResult = + connector.anyScalarNullableListOfNullableGetAllByTagAndValue.execute { + this.value = anyValue + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullableListOfNonNullable @table { value: [Any!], tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullableListOfNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { verifyAnyScalarNullableListOfNonNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNullableListOfNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + withClue("value=$value") { + verifyAnyScalarNullableListOfNonNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + } + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarNullableListOfNonNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNullableListOfNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + verifyAnyScalarNullableListOfNonNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + val key = connector.anyScalarNullableListOfNonNullableInsert.execute {}.data.key + val queryResult = connector.anyScalarNullableListOfNonNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableListOfNonNullableInsert3 + .execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + .data + val keyIds = listOf(keys.key1, keys.key2, keys.key3).map { it.id } + + val queryResult = + connector.anyScalarNullableListOfNonNullableGetAllByTagAndValue.execute { this.tag = tag } + val queryIds = queryResult.data.items.map { it.id } + queryIds shouldContainExactlyInAnyOrder keyIds + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + val key = connector.anyScalarNullableListOfNonNullableInsert.execute { value = null }.data.key + val queryResult = connector.anyScalarNullableListOfNonNullableGetByKey.execute(key) + queryResult.data.asClue { it.item?.value.shouldBeNull() } + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + connector.anyScalarNullableListOfNonNullableInsert3.execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + + val queryResult = + connector.anyScalarNullableListOfNonNullableGetAllByTagAndValue.execute { + this.tag = tag + this.value = null + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldBeEmpty() + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNullableListOfNonNullableInsert3 + .execute { + this.tag = tag + this.value1 = null + this.value2 = emptyList() + this.value3 = values.next() + } + .data + + val queryResult = + connector.anyScalarNullableListOfNonNullableGetAllByTagAndValue.execute { + this.tag = tag + this.value = emptyList() + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key2.id, keys.key3.id) + } + + private suspend fun verifyAnyScalarNullableListOfNonNullableRoundTrip(value: List?) { + val anyValue = value?.map { AnyValue.fromAny(it) } + val expectedQueryResult = value?.map { AnyValue.fromAny(expectedAnyScalarRoundTripValue(it)) } + val key = + connector.anyScalarNullableListOfNonNullableInsert.execute { this.value = anyValue }.data.key + + val queryResult = connector.anyScalarNullableListOfNonNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNullableListOfNonNullableGetByKeyQuery.Data( + AnyScalarNullableListOfNonNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNullableListOfNonNullableQueryVariable( + value: List?, + value2: List?, + value3: List?, + ) { + require(value != value2) + require(value != value3) + // TODO: implement a check to ensure that value is not a subset of value2 and value3. + + val tag = UUID.randomUUID().toString() + val anyValue = value?.map(AnyValue::fromAny) + val anyValue2 = value2?.map(AnyValue::fromAny) + val anyValue3 = value3?.map(AnyValue::fromAny) + val keys = + connector.anyScalarNullableListOfNonNullableInsert3 + .execute { + this.tag = tag + this.value1 = anyValue + this.value2 = anyValue2 + this.value3 = anyValue3 + } + .data + + val queryResult = + connector.anyScalarNullableListOfNonNullableGetAllByTagAndValue.execute { + this.value = anyValue + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullableListOfNullable @table { value: [Any]!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullableListOfNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { verifyAnyScalarNonNullableListOfNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + withClue("value=$value") { + verifyAnyScalarNonNullableListOfNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarNonNullableListOfNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + verifyAnyScalarNonNullableListOfNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableListOfNullableInsert.verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableListOfNullableGetAllByTagAndValue + .verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableListOfNullableInsert.verifyFailsWithNullVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableListOfNullableGetAllByTagAndValue + .verifyFailsWithNullVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNonNullableListOfNullableInsert3 + .execute(value1 = emptyList(), value2 = values.next(), value3 = values.next()) { + this.tag = tag + } + .data + + val queryResult = + connector.anyScalarNonNullableListOfNullableGetAllByTagAndValue.execute(value = emptyList()) { + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id, keys.key2.id, keys.key3.id) + } + + private suspend fun verifyAnyScalarNonNullableListOfNullableRoundTrip(value: List) { + val anyValue = value.map { AnyValue.fromAny(it) } + val expectedQueryResult = value.map { AnyValue.fromAny(expectedAnyScalarRoundTripValue(it)) } + val key = connector.anyScalarNonNullableListOfNullableInsert.execute(anyValue) {}.data.key + + val queryResult = connector.anyScalarNonNullableListOfNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNonNullableListOfNullableGetByKeyQuery.Data( + AnyScalarNonNullableListOfNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNonNullableListOfNullableQueryVariable( + value: List, + value2: List, + value3: List, + ) { + require(value != value2) + require(value != value3) + // TODO: implement a check to ensure that value is not a subset of value2 and value3. + + val tag = UUID.randomUUID().toString() + val anyValue = value.map(AnyValue::fromAny) + val anyValue2 = value2.map(AnyValue::fromAny) + val anyValue3 = value3.map(AnyValue::fromAny) + val keys = + connector.anyScalarNonNullableListOfNullableInsert3 + .execute(value1 = anyValue, value2 = anyValue2, value3 = anyValue3) { this.tag = tag } + .data + + val queryResult = + connector.anyScalarNonNullableListOfNullableGetAllByTagAndValue.execute(anyValue) { + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullableListOfNonNullable @table { + // value: [Any!]!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { verifyAnyScalarNonNullableListOfNonNullableRoundTrip(value) } + } + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + withClue("value=$value") { + verifyAnyScalarNonNullableListOfNonNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarNonNullableListOfNonNullableRoundTrip(value) + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + verifyAnyScalarNonNullableListOfNonNullableQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next() + ) + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableListOfNonNullableInsert.verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + connector.anyScalarNonNullableListOfNonNullableGetAllByTagAndValue + .verifyFailsWithMissingVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableListOfNonNullableInsert.verifyFailsWithNullVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryFailsIfAnyVariableIsNull() = runTest { + connector.anyScalarNonNullableListOfNonNullableGetAllByTagAndValue + .verifyFailsWithNullVariableValue() + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() }.map { it.map(AnyValue::fromAny) } + val tag = UUID.randomUUID().toString() + val keys = + connector.anyScalarNonNullableListOfNonNullableInsert3 + .execute(value1 = emptyList(), value2 = values.next(), value3 = values.next()) { + this.tag = tag + } + .data + + val queryResult = + connector.anyScalarNonNullableListOfNonNullableGetAllByTagAndValue.execute( + value = emptyList() + ) { + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id, keys.key2.id, keys.key3.id) + } + + private suspend fun verifyAnyScalarNonNullableListOfNonNullableRoundTrip(value: List) { + val anyValue = value.map { AnyValue.fromAny(it) } + val expectedQueryResult = value.map { AnyValue.fromAny(expectedAnyScalarRoundTripValue(it)) } + val key = connector.anyScalarNonNullableListOfNonNullableInsert.execute(anyValue) {}.data.key + + val queryResult = connector.anyScalarNonNullableListOfNonNullableGetByKey.execute(key) + queryResult.data shouldBe + AnyScalarNonNullableListOfNonNullableGetByKeyQuery.Data( + AnyScalarNonNullableListOfNonNullableGetByKeyQuery.Data.Item(expectedQueryResult) + ) + } + + private suspend fun verifyAnyScalarNonNullableListOfNonNullableQueryVariable( + value: List, + value2: List, + value3: List, + ) { + require(value != value2) + require(value != value3) + // TODO: implement a check to ensure that value is not a subset of value2 and value3. + + val tag = UUID.randomUUID().toString() + val anyValue = value.map(AnyValue::fromAny) + val anyValue2 = value2.map(AnyValue::fromAny) + val anyValue3 = value3.map(AnyValue::fromAny) + val keys = + connector.anyScalarNonNullableListOfNonNullableInsert3 + .execute(value1 = anyValue, value2 = anyValue2, value3 = anyValue3) { this.tag = tag } + .data + + val queryResult = + connector.anyScalarNonNullableListOfNonNullableGetAllByTagAndValue.execute(anyValue) { + this.tag = tag + } + val queryIds = queryResult.data.items.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // End of tests; everything below is helper functions and classes. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Serializable private data class VariablesWithNullValue(val value: String?) + + private companion object { + + @OptIn(ExperimentalKotest::class) + val normalCasePropTestConfig = + PropTestConfig(iterations = 5, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.0)) + + suspend fun GeneratedMutation<*, *, *>.verifyFailsWithMissingVariableValue() { + val mutationRef = + connector.dataConnect.mutation( + operationName = operationName, + variables = Unit, + dataDeserializer = dataDeserializer, + variablesSerializer = serializer(), + ) + mutationRef.verifyExecuteFailsDueToMissingVariable() + } + + suspend fun GeneratedQuery<*, *, *>.verifyFailsWithMissingVariableValue() { + val queryRef = + connector.dataConnect.query( + operationName = operationName, + variables = Unit, + dataDeserializer = dataDeserializer, + variablesSerializer = serializer(), + ) + queryRef.verifyExecuteFailsDueToMissingVariable() + } + + suspend fun OperationRef<*, *>.verifyExecuteFailsDueToMissingVariable() { + val exception = shouldThrow { execute() } + exception.message shouldContainIgnoringCase "\$value is missing" + } + + suspend fun GeneratedMutation<*, *, *>.verifyFailsWithNullVariableValue() { + val mutationRef = + connector.dataConnect.mutation( + operationName = operationName, + variables = VariablesWithNullValue(null), + dataDeserializer = dataDeserializer, + variablesSerializer = serializer(), + ) + + mutationRef.verifyExecuteFailsDueToNullVariable() + } + + suspend fun GeneratedQuery<*, *, *>.verifyFailsWithNullVariableValue() { + val queryRef = + connector.dataConnect.query( + operationName = operationName, + variables = VariablesWithNullValue(null), + dataDeserializer = dataDeserializer, + variablesSerializer = serializer(), + ) + + queryRef.verifyExecuteFailsDueToNullVariable() + } + + suspend fun OperationRef<*, *>.verifyExecuteFailsDueToNullVariable() { + val exception = shouldThrow { execute() } + exception.message shouldContainIgnoringCase "\$value is null" + } + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt new file mode 100644 index 00000000000..ba1ba058141 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt @@ -0,0 +1,450 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.generated.GeneratedMutation +import com.google.firebase.dataconnect.generated.GeneratedQuery +import com.google.firebase.dataconnect.testutil.MAX_DATE +import com.google.firebase.dataconnect.testutil.MIN_DATE +import com.google.firebase.dataconnect.testutil.ZERO_DATE +import com.google.firebase.dataconnect.testutil.assertThrows +import com.google.firebase.dataconnect.testutil.dateFromYearMonthDayUTC +import com.google.firebase.dataconnect.testutil.executeWithEmptyVariables +import com.google.firebase.dataconnect.testutil.randomDate +import com.google.firebase.dataconnect.testutil.withDataDeserializer +import com.google.firebase.dataconnect.testutil.withVariablesSerializer +import java.util.Date +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Test + +class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun insertTypicalValueForNonNullField() = runTest { + val date = dateFromYearMonthDayUTC(1944, 1, 1) + val key = connector.insertNonNullDate.execute(date).data.key + assertNonNullDateByKeyEquals(key, "1944-01-01") + } + + @Test + fun insertMaxValueForNonNullDateField() = runTest { + val key = connector.insertNonNullDate.execute(MIN_DATE).data.key + assertNonNullDateByKeyEquals(key, "1583-01-01") + } + + @Test + fun insertMinValueForNonNullDateField() = runTest { + val key = connector.insertNonNullDate.execute(MAX_DATE).data.key + assertNonNullDateByKeyEquals(key, "9999-12-31") + } + + @Test + fun insertValueWithTimeForNonNullDateField() = runTest { + // Use a date that, when converted to UTC, in on a different date to verify that the server does + // the expected thing; that is, that it _drops_ the time zone information (rather than + // converting the date to UTC then taking the YYYY-MM-DD of that). The server would use the date + // "2024-03-27" if it did the erroneous conversion to UTC before taking the YYYY-MM-DD. + val date = "2024-03-26T19:48:00.144-07:00" + val key = connector.insertNonNullDate.executeWithStringVariables(date).data.key + assertNonNullDateByKeyEquals(key, dateFromYearMonthDayUTC(2024, 3, 26)) + } + + @Test + fun insertDateNotOnExactDateBoundaryForNonNullDateField() = runTest { + val dateOnDateBoundary = dateFromYearMonthDayUTC(2000, 9, 14) + val dateOffDateBoundary = Date(dateOnDateBoundary.time + 7200) + + val key = connector.insertNonNullDate.execute(dateOffDateBoundary).data.key + assertNonNullDateByKeyEquals(key, dateOnDateBoundary) + } + + @Test + fun insertNoVariablesForNonNullDateFieldsWithSchemaDefaults() = runTest { + val key = connector.insertNonNullDatesWithDefaults.execute {}.data.key + val queryResult = connector.getNonNullDatesWithDefaultsByKey.execute(key) + + // Since we can't know the exact value of `request.time` just make sure that the exact same + // value is used for both fields to which it is set. + val expectedRequestTime = queryResult.data.nonNullDatesWithDefaults!!.requestTime1 + + assertThat( + queryResult.equals( + GetNonNullDatesWithDefaultsByKeyQuery.Data( + GetNonNullDatesWithDefaultsByKeyQuery.Data.NonNullDatesWithDefaults( + valueWithVariableDefault = dateFromYearMonthDayUTC(6904, 11, 30), + valueWithSchemaDefault = dateFromYearMonthDayUTC(2112, 1, 31), + epoch = ZERO_DATE, + requestTime1 = expectedRequestTime, + requestTime2 = expectedRequestTime, + ) + ) + ) + ) + } + + @Test + fun insertNullForNonNullDateFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullDate.executeWithStringVariables(null).data.key + } + } + + @Test + fun insertIntForNonNullDateFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullDate.executeWithIntVariables(999_888).data.key + } + } + + @Test + fun insertWithMissingValueNonNullDateFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullDate.executeWithEmptyVariables().data.key + } + } + + @Test + fun insertInvalidDatesValuesForNonNullDateFieldShouldFail() = runTest { + for (invalidDate in invalidDates) { + assertThrows(DataConnectException::class) { + connector.insertNonNullDate.executeWithStringVariables(invalidDate).data.key + } + } + } + + @Test + fun updateNonNullDateFieldToAnotherValidValue() = runTest { + val date1 = randomDate() + val date2 = dateFromYearMonthDayUTC(5654, 12, 1) + val key = connector.insertNonNullDate.execute(date1).data.key + connector.updateNonNullDate.execute(key) { value = date2 } + assertNonNullDateByKeyEquals(key, "5654-12-01") + } + + @Test + fun updateNonNullDateFieldToMinValue() = runTest { + val date = randomDate() + val key = connector.insertNonNullDate.execute(date).data.key + connector.updateNonNullDate.execute(key) { value = MIN_DATE } + assertNonNullDateByKeyEquals(key, "1583-01-01") + } + + @Test + fun updateNonNullDateFieldToMaxValue() = runTest { + val date = randomDate() + val key = connector.insertNonNullDate.execute(date).data.key + connector.updateNonNullDate.execute(key) { value = MAX_DATE } + assertNonNullDateByKeyEquals(key, "9999-12-31") + } + + @Test + fun updateNonNullDateFieldToAnUndefinedValue() = runTest { + val date = randomDate() + val key = connector.insertNonNullDate.execute(date).data.key + connector.updateNonNullDate.execute(key) {} + assertNonNullDateByKeyEquals(key, date) + } + + @Test + fun insertTypicalValueForNullableField() = runTest { + val date = dateFromYearMonthDayUTC(7611, 12, 1) + val key = connector.insertNullableDate.execute { value = date }.data.key + assertNullableDateByKeyEquals(key, "7611-12-01") + } + + @Test + fun insertMaxValueForNullableDateField() = runTest { + val key = connector.insertNullableDate.execute { value = MIN_DATE }.data.key + assertNullableDateByKeyEquals(key, "1583-01-01") + } + + @Test + fun insertMinValueForNullableDateField() = runTest { + val key = connector.insertNullableDate.execute { value = MAX_DATE }.data.key + assertNullableDateByKeyEquals(key, "9999-12-31") + } + + @Test + fun insertNullForNullableDateField() = runTest { + val key = connector.insertNullableDate.execute { value = null }.data.key + assertNullableDateByKeyEquals(key, null) + } + + @Test + fun insertUndefinedForNullableDateField() = runTest { + val key = connector.insertNullableDate.execute {}.data.key + assertNullableDateByKeyEquals(key, null) + } + + @Test + fun insertValueWithTimeForNullableDateField() = runTest { + // Use a date that, when converted to UTC, in on a different date to verify that the server does + // the expected thing; that is, that it _drops_ the time zone information (rather than + // converting the date to UTC then taking the YYYY-MM-DD of that). The server would use the date + // "2024-03-27" if it did the erroneous conversion to UTC before taking the YYYY-MM-DD. + val date = "2024-03-26T19:48:00.144-07:00" + val key = connector.insertNullableDate.executeWithStringVariables(date).data.key + assertNullableDateByKeyEquals(key, dateFromYearMonthDayUTC(2024, 3, 26)) + } + + @Test + fun insertDateNotOnExactDateBoundaryForNullableDateField() = runTest { + val dateOnDateBoundary = dateFromYearMonthDayUTC(1812, 12, 22) + val dateOffDateBoundary = Date(dateOnDateBoundary.time + 7200) + + val key = connector.insertNullableDate.execute { value = dateOffDateBoundary }.data.key + assertNullableDateByKeyEquals(key, dateOnDateBoundary) + } + + @Test + fun insertIntForNullableDateFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNullableDate.executeWithIntVariables(999_888).data.key + } + } + + @Test + fun insertInvalidDatesValuesForNullableDateFieldShouldFail() = runTest { + for (invalidDate in invalidDates) { + assertThrows(DataConnectException::class) { + connector.insertNullableDate.executeWithStringVariables(invalidDate).data.key + } + } + } + + @Test + fun insertNoVariablesForNullableDateFieldsWithSchemaDefaults() = runTest { + val key = connector.insertNullableDatesWithDefaults.execute {}.data.key + val queryResult = connector.getNullableDatesWithDefaultsByKey.execute(key) + + // Since we can't know the exact value of `request.time` just make sure that the exact same + // value is used for both fields to which it is set. + val expectedRequestTime = queryResult.data.nullableDatesWithDefaults!!.requestTime1 + + assertThat( + queryResult.equals( + GetNullableDatesWithDefaultsByKeyQuery.Data( + GetNullableDatesWithDefaultsByKeyQuery.Data.NullableDatesWithDefaults( + valueWithVariableDefault = dateFromYearMonthDayUTC(8113, 2, 9), + valueWithSchemaDefault = dateFromYearMonthDayUTC(1921, 12, 2), + epoch = ZERO_DATE, + requestTime1 = expectedRequestTime, + requestTime2 = expectedRequestTime, + ) + ) + ) + ) + } + + @Test + fun updateNullableDateFieldToAnotherValidValue() = runTest { + val date1 = randomDate() + val date2 = dateFromYearMonthDayUTC(5654, 12, 1) + val key = connector.insertNullableDate.execute { value = date1 }.data.key + connector.updateNullableDate.execute(key) { value = date2 } + assertNullableDateByKeyEquals(key, "5654-12-01") + } + + @Test + fun updateNullableDateFieldToMinValue() = runTest { + val date = randomDate() + val key = connector.insertNullableDate.execute { value = date }.data.key + connector.updateNullableDate.execute(key) { value = MIN_DATE } + assertNullableDateByKeyEquals(key, "1583-01-01") + } + + @Test + fun updateNullableDateFieldToMaxValue() = runTest { + val date = randomDate() + val key = connector.insertNullableDate.execute { value = date }.data.key + connector.updateNullableDate.execute(key) { value = MAX_DATE } + assertNullableDateByKeyEquals(key, "9999-12-31") + } + + @Test + fun updateNullableDateFieldToNull() = runTest { + val date = randomDate() + val key = connector.insertNullableDate.execute { value = date }.data.key + connector.updateNullableDate.execute(key) { value = null } + assertNullableDateByKeyEquals(key, null) + } + + @Test + fun updateNullableDateFieldToNonNull() = runTest { + val date = randomDate() + val key = connector.insertNullableDate.execute { value = null }.data.key + connector.updateNullableDate.execute(key) { value = date } + assertNullableDateByKeyEquals(key, date) + } + + @Test + fun updateNullableDateFieldToAnUndefinedValue() = runTest { + val date = randomDate() + val key = connector.insertNullableDate.execute { value = date }.data.key + connector.updateNullableDate.execute(key) {} + assertNullableDateByKeyEquals(key, date) + } + + private suspend fun assertNonNullDateByKeyEquals(key: NonNullDateKey, expected: String) { + val queryResult = + connector.getNonNullDateByKey + .withDataDeserializer(serializer()) + .execute(key) + assertThat(queryResult.data).isEqualTo(GetDateByKeyQueryStringData(expected)) + } + + private suspend fun assertNonNullDateByKeyEquals(key: NonNullDateKey, expected: Date) { + val queryResult = connector.getNonNullDateByKey.execute(key) + assertThat(queryResult.data) + .isEqualTo(GetNonNullDateByKeyQuery.Data(GetNonNullDateByKeyQuery.Data.Value(expected))) + } + + private suspend fun assertNullableDateByKeyEquals(key: NullableDateKey, expected: String) { + val queryResult = + connector.getNullableDateByKey + .withDataDeserializer(serializer()) + .execute(key) + assertThat(queryResult.data).isEqualTo(GetDateByKeyQueryStringData(expected)) + } + + private suspend fun assertNullableDateByKeyEquals(key: NullableDateKey, expected: Date?) { + val queryResult = connector.getNullableDateByKey.execute(key) + assertThat(queryResult.data) + .isEqualTo(GetNullableDateByKeyQuery.Data(GetNullableDateByKeyQuery.Data.Value(expected))) + } + + /** + * A `Data` type that can be used in place of [GetNonNullDateByKeyQuery.Data] that types the value + * as a [String] instead of a [Date], allowing verification of the data sent over the wire without + * possible confounding from date deserialization. + */ + @Serializable + private data class GetDateByKeyQueryStringData(val value: DateStringValue?) { + constructor(value: String) : this(DateStringValue(value)) + @Serializable data class DateStringValue(val value: String) + } + + /** + * A `Variables` type that can be used in place of [InsertNonNullDateMutation.Variables] that + * types the value as a [String] instead of a [Date], allowing verification of the data sent over + * the wire without possible confounding from date serialization. + */ + @Serializable private data class InsertDateStringVariables(val value: String?) + + /** + * A `Variables` type that can be used in place of [InsertNonNullDateMutation.Variables] that + * types the value as a [Int] instead of a [Date], allowing verification that the server fails + * with an expected error (rather than crashing, for example). + */ + @Serializable private data class InsertDateIntVariables(val value: Int) + + private companion object { + + suspend fun GeneratedMutation<*, Data, *>.executeWithStringVariables(value: String?) = + withVariablesSerializer(serializer()) + .ref(InsertDateStringVariables(value)) + .execute() + + suspend fun GeneratedMutation<*, Data, *>.executeWithIntVariables(value: Int) = + withVariablesSerializer(serializer()) + .ref(InsertDateIntVariables(value)) + .execute() + + suspend fun GeneratedQuery<*, Data, GetNonNullDateByKeyQuery.Variables>.execute( + key: NonNullDateKey + ) = ref(GetNonNullDateByKeyQuery.Variables(key)).execute() + + suspend fun GeneratedQuery<*, Data, GetNullableDateByKeyQuery.Variables>.execute( + key: NullableDateKey + ) = ref(GetNullableDateByKeyQuery.Variables(key)).execute() + + val invalidDates = + listOf( + // Partial dates + "2", + "20", + "202", + "2024", + "2024-", + "2024-0", + "2024-01", + "2024-01-", + "2024-01-0", + "2024-01-04T", + + // Missing components + "", + "2024-", + "-05-17", + "2024-05", + "2024--17", + "-05-", + + // Invalid year + "2-05-17", + "20-05-17", + "202-05-17", + "20245-05-17", + "02024-05-17", + "ABCD-05-17", + "-123-05-17", + + // Invalid month + "2024-1-17", + "2024-012-17", + "2024-123-17", + "2024-00-17", + "2024-13-17", + "2024-M-17", + "2024-MA-17", + + // Invalid day + "2024-05-1", + "2024-05-123", + "2024-05-012", + "2024-05-00", + "2024-05-32", + "2024-05-A", + "2024-05-AB", + "2024-05-ABC", + + // Out-of-range Values + "0000-01-01", + "2024-00-22", + "2024-13-22", + "2024-11-00", + "2024-01-32", + "2025-02-29", + "2024-02-30", + "2024-03-32", + "2024-04-31", + "2024-05-32", + "2024-06-31", + "2024-07-32", + "2024-08-32", + "2024-09-31", + "2024-10-32", + "2024-11-31", + "2024-12-32", + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorIntegrationTest.kt new file mode 100644 index 00000000000..1485367e203 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorIntegrationTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import java.util.concurrent.Executors +import java.util.concurrent.Future +import org.junit.Test + +class DemoConnectorIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun getFooById_ShouldAlwaysReturnTheExactSameObject() { + verifyBlockInvokedConcurrentlyAlwaysReturnsTheSameObject { connector.getFooById } + } + + @Test + fun equals_ShouldReturnFalseWhenArgumentIsNull() { + assertThat(connector.equals(null)).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenArgumentIsAnInstanceOfADifferentClass() { + assertThat(connector.equals("foo")).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenInvokedOnADistinctObject() { + assertThat(connector.equals(demoConnectorFactory.newInstance())).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenInvokedOnTheSameObjectAfterClose() { + val connector1 = demoConnectorFactory.newInstance() + connector1.dataConnect.close() + val connector2 = demoConnectorFactory.newInstance() + assertThat(connector1).isNotSameInstanceAs(connector2) + + assertThat(connector1.equals(connector2)).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenInvokedOnAnApparentlyEqualButDifferentImplementation() { + val connectorAlternateImpl = DemoConnectorAlternateImpl(connector) + + assertThat(connector.equals(connectorAlternateImpl)).isFalse() + } + + @Test + fun hashCode_ShouldReturnSameValueOnEachInvocation() { + val hashCode1 = connector.hashCode() + val hashCode2 = connector.hashCode() + + assertThat(hashCode1).isEqualTo(hashCode2) + } + + @Test + fun hashCode_ShouldReturnDistinctValuesOnDistinctInstances() { + val hashCode1 = demoConnectorFactory.newInstance().hashCode() + val hashCode2 = demoConnectorFactory.newInstance().hashCode() + + assertThat(hashCode1).isNotEqualTo(hashCode2) + } + + @Test + fun toString_ShouldReturnAStringThatStartsWithClassName() { + assertThat("$connector").startsWith("DemoConnectorImpl(") + assertThat("$connector").endsWith(")") + } + + @Test + fun toString_ShouldReturnAStringThatContainsTheToStringOfTheDataConnectInstance() { + assertThat("$connector").containsWithNonAdjacentText("dataConnect=${connector.dataConnect}") + } + + class DemoConnectorAlternateImpl(delegate: DemoConnector) : DemoConnector by delegate + + // TODO: Write tests for each property in DemoConnector. + + private fun verifyBlockInvokedConcurrentlyAlwaysReturnsTheSameObject(block: () -> T) { + val results = mutableListOf() + val futures = mutableListOf>() + val executor = Executors.newFixedThreadPool(6) + try { + repeat(1000) { + executor + .submit { + val result = block() + synchronized(results) { results.add(result) } + } + .also { futures.add(it) } + } + + futures.forEach { it.get() } + } finally { + executor.shutdownNow() + } + + assertWithMessage("results.size").that(results.size).isGreaterThan(0) + val expectedResults = List(1000) { results[0] } + assertWithMessage("results").that(results).containsExactlyElementsIn(expectedResults) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt new file mode 100644 index 00000000000..5d27737e003 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.* +import com.google.firebase.dataconnect.testutil.* +import java.util.UUID +import kotlin.random.Random +import kotlinx.coroutines.test.* +import org.junit.Ignore +import org.junit.Test + +class KeyVariablesIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun primaryKeyIsAString() = runTest { + val id = randomAlphanumericString() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsString.execute(id = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsStringByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsString) + .isEqualTo(GetPrimaryKeyIsStringByKeyQuery.Data.PrimaryKeyIsString(id = id, value = value)) + } + + @Test + fun primaryKeyIsUUID() = runTest { + val id = UUID.randomUUID() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsUuid.execute(id = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsUuidByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsUUID) + .isEqualTo(GetPrimaryKeyIsUuidByKeyQuery.Data.PrimaryKeyIsUuid(id = id, value = value)) + } + + @Test + fun primaryKeyIsInt() = runTest { + val id = Random.nextInt() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsInt.execute(foo = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsIntByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsInt) + .isEqualTo(GetPrimaryKeyIsIntByKeyQuery.Data.PrimaryKeyIsInt(foo = id, value = value)) + } + + @Test + fun primaryKeyIsFloat() = runTest { + val id = Random.nextDouble() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsFloat.execute(foo = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsFloatByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsFloat) + .isEqualTo(GetPrimaryKeyIsFloatByKeyQuery.Data.PrimaryKeyIsFloat(foo = id, value = value)) + } + + @Test + fun primaryKeyIsDate() = runTest { + val id = randomDate() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsDate.execute(foo = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsDateByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsDate) + .isEqualTo(GetPrimaryKeyIsDateByKeyQuery.Data.PrimaryKeyIsDate(foo = id, value = value)) + } + + @Test + fun primaryKeyIsTimestamp() = runTest { + val id = randomTimestamp() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsTimestamp.execute(foo = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsTimestampByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsTimestamp) + .isEqualTo( + GetPrimaryKeyIsTimestampByKeyQuery.Data.PrimaryKeyIsTimestamp( + foo = id.withMicrosecondPrecision(), + value = value + ) + ) + } + + @Test + fun primaryKeyIsInt64() = runTest { + val id = Random.nextLong() + val value = randomAlphanumericString() + + val key = connector.insertPrimaryKeyIsInt64.execute(foo = id, value = value).data.key + + val queryResult = connector.getPrimaryKeyIsInt64byKey.execute(key) + assertThat(queryResult.data.primaryKeyIsInt64) + .isEqualTo(GetPrimaryKeyIsInt64byKeyQuery.Data.PrimaryKeyIsInt64(foo = id, value = value)) + } + + @Test + fun primaryKeyIsComposite() = runTest { + val foo = Random.nextInt() + val bar = randomAlphanumericString() + val baz = Random.nextBoolean() + val value = randomAlphanumericString() + + val key = + connector.insertPrimaryKeyIsComposite + .execute(foo = foo, bar = bar, baz = baz, value = value) + .data + .key + + val queryResult = connector.getPrimaryKeyIsCompositeByKey.execute(key) + assertThat(queryResult.data.primaryKeyIsComposite) + .isEqualTo( + GetPrimaryKeyIsCompositeByKeyQuery.Data.PrimaryKeyIsComposite( + foo = foo, + bar = bar, + baz = baz, + value = value + ) + ) + } + + @Ignore( + "Re-enable this test once b/336925985 is fixed " + + "(Flattened primary key field names character case mismatch)" + ) + @Test + fun primaryKeyIsNested() = runTest { + val nested1s = listOf(createPrimaryKeyNested1(), createPrimaryKeyNested1()) + val nested2s = listOf(createPrimaryKeyNested2(), createPrimaryKeyNested2()) + val nested3 = createPrimaryKeyNested3() + val nested4 = createPrimaryKeyNested4() + val nested5a = createPrimaryKeyNested5(nested1s[0].key, nested2s[0].key) + val nested5b = createPrimaryKeyNested5(nested1s[1].key, nested2s[1].key) + val nested6 = createPrimaryKeyNested6(nested3.key, nested4.key) + val nested7 = createPrimaryKeyNested7(nested5a.key, nested5b.key, nested6.key) + + val queryResult = connector.getPrimaryKeyNested7byKey.execute(nested7.key) + + assertThat(queryResult.data) + .isEqualTo( + GetPrimaryKeyNested7byKeyQuery.Data( + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7( + nested7.value, + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5a( + nested5a.value, + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5a.Nested1( + nested1s[0].key.id, + nested1s[0].value + ), + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5a.Nested2( + nested2s[0].key.id, + nested2s[0].value + ), + ), + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5b( + nested5b.value, + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5b.Nested1( + nested1s[1].key.id, + nested1s[1].value + ), + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested5b.Nested2( + nested2s[1].key.id, + nested2s[1].value + ), + ), + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested6( + nested6.value, + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested6.Nested3( + nested3.key.id, + nested3.value + ), + GetPrimaryKeyNested7byKeyQuery.Data.PrimaryKeyNested7.Nested6.Nested4( + nested4.key.id, + nested4.value + ), + ), + ) + ) + ) + } + + data class PrimaryKeyNested1Info(val key: PrimaryKeyNested1Key, val value: String) + + private suspend fun createPrimaryKeyNested1(): PrimaryKeyNested1Info { + val value = randomAlphanumericString("nested1") + val key = connector.insertPrimaryKeyNested1.execute(value).data.key + return PrimaryKeyNested1Info(key, value) + } + + data class PrimaryKeyNested2Info(val key: PrimaryKeyNested2Key, val value: String) + + private suspend fun createPrimaryKeyNested2(): PrimaryKeyNested2Info { + val value = randomAlphanumericString("nested2") + val key = connector.insertPrimaryKeyNested2.execute(value).data.key + return PrimaryKeyNested2Info(key, value) + } + + data class PrimaryKeyNested3Info(val key: PrimaryKeyNested3Key, val value: String) + + private suspend fun createPrimaryKeyNested3(): PrimaryKeyNested3Info { + val value = randomAlphanumericString("nested3") + val key = connector.insertPrimaryKeyNested3.execute(value).data.key + return PrimaryKeyNested3Info(key, value) + } + + data class PrimaryKeyNested4Info(val key: PrimaryKeyNested4Key, val value: String) + + private suspend fun createPrimaryKeyNested4(): PrimaryKeyNested4Info { + val value = randomAlphanumericString("nested4") + val key = connector.insertPrimaryKeyNested4.execute(value).data.key + return PrimaryKeyNested4Info(key, value) + } + + data class PrimaryKeyNested5Info(val key: PrimaryKeyNested5Key, val value: String) + + private suspend fun createPrimaryKeyNested5( + nested1: PrimaryKeyNested1Key, + nested2: PrimaryKeyNested2Key + ): PrimaryKeyNested5Info { + val value = randomAlphanumericString("nested5") + val key = connector.insertPrimaryKeyNested5.execute(value, nested1, nested2).data.key + return PrimaryKeyNested5Info(key, value) + } + + data class PrimaryKeyNested6Info(val key: PrimaryKeyNested6Key, val value: String) + + private suspend fun createPrimaryKeyNested6( + nested3: PrimaryKeyNested3Key, + nested4: PrimaryKeyNested4Key + ): PrimaryKeyNested6Info { + val value = randomAlphanumericString("nested6") + val key = connector.insertPrimaryKeyNested6.execute(value, nested3, nested4).data.key + return PrimaryKeyNested6Info(key, value) + } + + data class PrimaryKeyNested7Info(val key: PrimaryKeyNested7Key, val value: String) + + private suspend fun createPrimaryKeyNested7( + nested5a: PrimaryKeyNested5Key, + nested5b: PrimaryKeyNested5Key, + nested6: PrimaryKeyNested6Key + ): PrimaryKeyNested7Info { + val value = randomAlphanumericString("nested7") + val key = + connector.insertPrimaryKeyNested7 + .execute(value, nested5a = nested5a, nested5b = nested5b, nested6 = nested6) + .data + .key + return PrimaryKeyNested7Info(key, value) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt new file mode 100644 index 00000000000..9cf007f47d2 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt @@ -0,0 +1,711 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Timestamp +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.testutil.MAX_DATE +import com.google.firebase.dataconnect.testutil.MAX_SAFE_INTEGER +import com.google.firebase.dataconnect.testutil.MAX_TIMESTAMP +import com.google.firebase.dataconnect.testutil.MIN_DATE +import com.google.firebase.dataconnect.testutil.MIN_TIMESTAMP +import com.google.firebase.dataconnect.testutil.dateFromYearMonthDayUTC +import com.google.firebase.dataconnect.testutil.withMicrosecondPrecision +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +class ListVariablesAndDataIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun insertNonNullableEmptyLists() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + .data + .key + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + ) + ) + } + + @Test + fun insertNonNullableNonEmptyLists() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("e7c0b51d-55ec-4c7f-b831-038e6377c4bc"), + UUID.fromString("6365f797-3d23-482c-9159-bc28b68b8b6e") + ), + int64s = listOf(1, 2, 3), + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + .data + .key + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("e7c0b51d-55ec-4c7f-b831-038e6377c4bc"), + UUID.fromString("6365f797-3d23-482c-9159-bc28b68b8b6e") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } + + @Ignore( + "b/339440054 Fix this test once -0.0 is correctly sent from the backend " + + "instead of being converted to 0.0" + ) + @Test + fun floatCorrectlySerializesNegativeZero() { + TODO( + "this test is merely a placeholder as a reminder " + + "and should be removed once the test is updated" + ) + } + + @Test + fun insertNonNullableListsWithExtremeValues() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = listOf(""), + ints = listOf(0, 1, -1, Int.MAX_VALUE, Int.MIN_VALUE), + // TODO(b/339440054) add -0.0 to the list once the bug is fixed + floats = listOf(0.0, 1.0, -1.0, Double.MAX_VALUE, Double.MIN_VALUE, MAX_SAFE_INTEGER), + booleans = emptyList(), // Boolean have no "extreme" values + uuids = emptyList(), // UUID have no "extreme" values + int64s = + listOf( + 0, + 1, + -1, + Int.MAX_VALUE.toLong(), + Int.MIN_VALUE.toLong(), + Long.MAX_VALUE, + Long.MIN_VALUE + ), + dates = listOf(MIN_DATE, MAX_DATE), + timestamps = listOf(MIN_TIMESTAMP, MAX_TIMESTAMP), + ) + .data + .key + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = listOf(""), + ints = listOf(0, 1, -1, Int.MAX_VALUE, Int.MIN_VALUE), + // TODO(b/339440054) add -0.0 to the list once the bug is fixed + floats = listOf(0.0, 1.0, -1.0, Double.MAX_VALUE, Double.MIN_VALUE, MAX_SAFE_INTEGER), + booleans = emptyList(), // Boolean have no "extreme" values + uuids = emptyList(), // UUID have no "extreme" values + int64s = + listOf( + 0, + 1, + -1, + Int.MAX_VALUE.toLong(), + Int.MIN_VALUE.toLong(), + Long.MAX_VALUE, + Long.MIN_VALUE + ), + dates = listOf(MIN_DATE, MAX_DATE), + timestamps = + listOf( + MIN_TIMESTAMP.withMicrosecondPrecision(), + MAX_TIMESTAMP.withMicrosecondPrecision() + ), + ) + ) + ) + } + + @Test + fun updateNonNullableEmptyListsToNonEmpty() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + .data + .key + + connector.updateNonNullableListsByKey.execute(key) { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("317835d2-efae-4981-b70f-64ff31126921"), + UUID.fromString("91597f71-8f85-4ae5-ac4d-909287c8c52c") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("317835d2-efae-4981-b70f-64ff31126921"), + UUID.fromString("91597f71-8f85-4ae5-ac4d-909287c8c52c") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } + + @Test + fun updateNonNullableNonEmptyListsToEmpty() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("e7c0b51d-55ec-4c7f-b831-038e6377c4bc"), + UUID.fromString("6365f797-3d23-482c-9159-bc28b68b8b6e") + ), + int64s = listOf(1, 2, 3), + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + .data + .key + + connector.updateNonNullableListsByKey.execute(key) { + strings = emptyList() + ints = emptyList() + floats = emptyList() + booleans = emptyList() + uuids = emptyList() + int64s = emptyList() + dates = emptyList() + timestamps = emptyList() + } + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + ) + ) + } + + @Test + fun updateNonNullableWithUndefinedLists() = runTest { + val key = + connector.insertNonNullableLists + .execute( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("e60688ca-baae-4f79-8ef1-908220148399"), + UUID.fromString("e2170f8a-9a53-478c-ae2f-9fb5b09da5c7") + ), + int64s = listOf(1, 2, 3), + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + .data + .key + + connector.updateNonNullableListsByKey.execute(key) {} + + val queryResult = connector.getNonNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNonNullableListsByKeyQuery.Data( + GetNonNullableListsByKeyQuery.Data.NonNullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("e60688ca-baae-4f79-8ef1-908220148399"), + UUID.fromString("e2170f8a-9a53-478c-ae2f-9fb5b09da5c7") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } + + @Test + fun insertNullableEmptyLists() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = emptyList() + ints = emptyList() + floats = emptyList() + booleans = emptyList() + uuids = emptyList() + int64s = emptyList() + dates = emptyList() + timestamps = emptyList() + } + .data + .key + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + ) + ) + } + + @Test + fun insertNullableUndefinedLists() = runTest { + val key = connector.insertNullableLists.execute {}.data.key + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = null, + ints = null, + floats = null, + booleans = null, + uuids = null, + int64s = null, + dates = null, + timestamps = null, + ) + ) + ) + } + + @Test + fun insertNullableNonEmptyLists() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("643da3eb-91cc-426f-850e-e6e4a0ef2060"), + UUID.fromString("66acc445-e384-4770-8524-279663e56bb3") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + .data + .key + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("643da3eb-91cc-426f-850e-e6e4a0ef2060"), + UUID.fromString("66acc445-e384-4770-8524-279663e56bb3") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } + + @Test + fun insertNullableListsWithExtremeValues() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = listOf("") + ints = listOf(0, 1, -1, Int.MAX_VALUE, Int.MIN_VALUE) + // TODO(b/339440054) add -0.0 to the list once the bug is fixed + floats = listOf(0.0, 1.0, -1.0, Double.MAX_VALUE, Double.MIN_VALUE, MAX_SAFE_INTEGER) + booleans = emptyList() // Boolean have no "extreme" values + uuids = emptyList() // UUID have no "extreme" values + int64s = + listOf( + 0, + 1, + -1, + Int.MAX_VALUE.toLong(), + Int.MIN_VALUE.toLong(), + Long.MAX_VALUE, + Long.MIN_VALUE + ) + dates = listOf(MIN_DATE, MAX_DATE) + timestamps = listOf(MIN_TIMESTAMP, MAX_TIMESTAMP) + } + .data + .key + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = listOf(""), + ints = listOf(0, 1, -1, Int.MAX_VALUE, Int.MIN_VALUE), + // TODO(b/339440054) add -0.0 to the list once the bug is fixed + floats = listOf(0.0, 1.0, -1.0, Double.MAX_VALUE, Double.MIN_VALUE, MAX_SAFE_INTEGER), + booleans = emptyList(), // Boolean have no "extreme" values + uuids = emptyList(), // UUID have no "extreme" values + int64s = + listOf( + 0, + 1, + -1, + Int.MAX_VALUE.toLong(), + Int.MIN_VALUE.toLong(), + Long.MAX_VALUE, + Long.MIN_VALUE + ), + dates = listOf(MIN_DATE, MAX_DATE), + timestamps = + listOf( + MIN_TIMESTAMP.withMicrosecondPrecision(), + MAX_TIMESTAMP.withMicrosecondPrecision() + ), + ) + ) + ) + } + + @Test + fun updateNullableEmptyListsToNonEmpty() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = emptyList() + ints = emptyList() + floats = emptyList() + booleans = emptyList() + uuids = emptyList() + int64s = emptyList() + dates = emptyList() + timestamps = emptyList() + } + .data + .key + + connector.updateNullableListsByKey.execute(key) { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("046b46f4-8a57-4611-ac1a-b2213278acad"), + UUID.fromString("80fa16ff-51ce-480a-b117-97a2d37d19f1") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("046b46f4-8a57-4611-ac1a-b2213278acad"), + UUID.fromString("80fa16ff-51ce-480a-b117-97a2d37d19f1") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } + + @Test + fun updateNullableNonEmptyListsToEmpty() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("a62c5afa-ded1-401c-aac4-a8e8d786a16f"), + UUID.fromString("1dbf3cd7-ed04-4edd-9b77-65465f9fbaef") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + .data + .key + + connector.updateNullableListsByKey.execute(key) { + strings = emptyList() + ints = emptyList() + floats = emptyList() + booleans = emptyList() + uuids = emptyList() + int64s = emptyList() + dates = emptyList() + timestamps = emptyList() + } + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = emptyList(), + ints = emptyList(), + floats = emptyList(), + booleans = emptyList(), + uuids = emptyList(), + int64s = emptyList(), + dates = emptyList(), + timestamps = emptyList(), + ) + ) + ) + } + + @Test + fun updateNullableNonEmptyListsToNull() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("a62c5afa-ded1-401c-aac4-a8e8d786a16f"), + UUID.fromString("1dbf3cd7-ed04-4edd-9b77-65465f9fbaef") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + .data + .key + + connector.updateNullableListsByKey.execute(key) { + strings = null + ints = null + floats = null + booleans = null + uuids = null + int64s = null + dates = null + timestamps = null + } + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = null, + ints = null, + floats = null, + booleans = null, + uuids = null, + int64s = null, + dates = null, + timestamps = null, + ) + ) + ) + } + + @Test + fun updateNullableWithUndefinedLists() = runTest { + val key = + connector.insertNullableLists + .execute { + strings = listOf("a", "b") + ints = listOf(1, 2, 3) + floats = listOf(1.1, 2.2, 3.3) + booleans = listOf(true, false, true, false) + uuids = + listOf( + UUID.fromString("505516e2-1af3-4a7a-afab-0fe4b8f2bc0d"), + UUID.fromString("f0afdbfc-10a1-4446-8823-3bfc81ff3162") + ) + int64s = listOf(1, 2, 3) + dates = listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)) + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)) + } + .data + .key + + connector.updateNullableListsByKey.execute(key) {} + + val queryResult = connector.getNullableListsByKey.execute(key) + + assertThat(queryResult.data) + .isEqualTo( + GetNullableListsByKeyQuery.Data( + GetNullableListsByKeyQuery.Data.NullableLists( + strings = listOf("a", "b"), + ints = listOf(1, 2, 3), + floats = listOf(1.1, 2.2, 3.3), + booleans = listOf(true, false, true, false), + uuids = + listOf( + UUID.fromString("505516e2-1af3-4a7a-afab-0fe4b8f2bc0d"), + UUID.fromString("f0afdbfc-10a1-4446-8823-3bfc81ff3162") + ), + int64s = listOf(1, 2, 3), + dates = + listOf(dateFromYearMonthDayUTC(2024, 5, 7), dateFromYearMonthDayUTC(1978, 3, 30)), + timestamps = listOf(Timestamp(123456789, 990000000), Timestamp(987654321, 110000000)), + ) + ) + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NestedStructsIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NestedStructsIntegrationTest.kt new file mode 100644 index 00000000000..d11c6a73eca --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NestedStructsIntegrationTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.* +import com.google.firebase.dataconnect.testutil.* +import kotlinx.coroutines.test.* +import org.junit.Test + +class NestedStructsIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun queryShouldCorrectlyDeserializeNestedStructs() = runTest { + val nested3s = createNested3s(8) + val nested3Keys = nested3s.map { it.key }.iterator() + val nested2s = createNested2s(4, nested3Keys) + val nested2Keys = nested2s.map { it.key }.iterator() + val nested1a = createNested1(nested1 = null, nested2Keys) + val nested1b = createNested1(nested1 = nested1a.key, nested2Keys) + + val queryResult = connector.getNested1byKey.execute(nested1b.key) + + assertThat(queryResult.data) + .isEqualTo( + GetNested1byKeyQuery.Data( + GetNested1byKeyQuery.Data.Nested1( + id = nested1b.key.id, + nested1 = + GetNested1byKeyQuery.Data.Nested1.Nested1( + id = nested1a.key.id, + nested1 = null, + nested2 = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2( + id = nested2s[0].key.id, + value = nested2s[0].value, + nested3 = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2.Nested3( + id = nested3s[0].key.id, + value = nested3s[0].value, + ), + nested3NullableNull = null, + nested3NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2.Nested3nullableNonNull( + id = nested3s[1].key.id, + value = nested3s[1].value, + ), + ), + nested2NullableNull = null, + nested2NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2nullableNonNull( + id = nested2s[1].key.id, + value = nested2s[1].value, + nested3 = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2nullableNonNull.Nested3( + id = nested3s[2].key.id, + value = nested3s[2].value, + ), + nested3NullableNull = null, + nested3NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested1.Nested2nullableNonNull + .Nested3nullableNonNull( + id = nested3s[3].key.id, + value = nested3s[3].value, + ), + ), + ), + nested2 = + GetNested1byKeyQuery.Data.Nested1.Nested2( + id = nested2s[2].key.id, + value = nested2s[2].value, + nested3 = + GetNested1byKeyQuery.Data.Nested1.Nested2.Nested3( + id = nested3s[4].key.id, + value = nested3s[4].value, + ), + nested3NullableNull = null, + nested3NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested2.Nested3nullableNonNull( + id = nested3s[5].key.id, + value = nested3s[5].value, + ), + ), + nested2NullableNull = null, + nested2NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested2nullableNonNull( + id = nested2s[3].key.id, + value = nested2s[3].value, + nested3 = + GetNested1byKeyQuery.Data.Nested1.Nested2nullableNonNull.Nested3( + id = nested3s[6].key.id, + value = nested3s[6].value, + ), + nested3NullableNull = null, + nested3NullableNonNull = + GetNested1byKeyQuery.Data.Nested1.Nested2nullableNonNull.Nested3nullableNonNull( + id = nested3s[7].key.id, + value = nested3s[7].value, + ), + ), + ) + ) + ) + } + + data class Nested3Info(val key: Nested3Key, val value: String) + + private suspend fun createNested3s(count: Int) = + List(count) { + val value = "nested3_${it}_" + randomAlphanumericString() + val key = connector.insertNested3.execute(value).data.key + Nested3Info(key, value) + } + + data class Nested2Info(val key: Nested2Key, val value: String) + + private suspend fun createNested2s(count: Int, nested3s: Iterator) = + List(count) { + val value = "nested2_${it}_" + randomAlphanumericString() + val key = + connector.insertNested2 + .execute(nested3 = nested3s.next(), value = value) { + nested3NullableNonNull = nested3s.next() + nested3NullableNull = null + } + .data + .key + Nested2Info(key, value) + } + + data class Nested1Info(val key: Nested1Key, val value: String) + + private suspend fun createNested1( + nested1: Nested1Key?, + nested2s: Iterator + ): Nested1Info { + val value = "nested1_1_" + randomAlphanumericString() + val key = + connector.insertNested1 + .execute(nested2 = nested2s.next(), value = value) { + this.nested1 = nested1 + nested2NullableNonNull = nested2s.next() + nested2NullableNull = null + } + .data + .key + return Nested1Info(key, value) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NoVariablesIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NoVariablesIntegrationTest.kt new file mode 100644 index 00000000000..4946afb7226 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NoVariablesIntegrationTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import kotlinx.coroutines.test.* +import org.junit.Test + +class NoVariablesQIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun queryExecuteShouldReturnTheResult() = runTest { + // Populate the database with the entry that will be fetched by the "NoVariablesQ" query. + connector.upsertHardcodedFoo.execute() + + val queryResult = connector.getHardcodedFoo.execute() + + assertThat(queryResult.ref).isEqualTo(connector.getHardcodedFoo.ref()) + assertThat(queryResult.data.foo?.bar).isEqualTo("BAR") + } + + @Test + fun mutationExecuteShouldReturnTheResult() = runTest { + val mutationResult = connector.upsertHardcodedFoo.execute() + + assertThat(mutationResult.ref).isEqualTo(connector.upsertHardcodedFoo.ref()) + assertThat(mutationResult.data.key).isEqualTo(FooKey("18e61f0a-8abc-4b18-9c4c-28c2f4e82c8f")) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OnAndViaRelationsIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OnAndViaRelationsIntegrationTest.kt new file mode 100644 index 00000000000..4bcbcd1ee1e --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OnAndViaRelationsIntegrationTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.* +import com.google.firebase.dataconnect.testutil.* +import kotlinx.coroutines.test.* +import org.junit.Ignore +import org.junit.Test + +/** See go/firemat:api:relations */ +class OnAndViaRelationsIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun manyToOne() = runTest { + val children = List(2) { connector.insertManyToOneChild.execute().data.key } + val parents = + List(6) { + val childKey = children[it % children.size] + connector.insertManyToOneParent.execute { child = childKey }.data.key + } + + val queryResult = connector.getManyToOneChildByKey.execute(children[0]) + + assertThat(queryResult.data.manyToOneChild?.parents) + .containsExactly( + GetManyToOneChildByKeyQuery.Data.ManyToOneChild.ParentsItem(parents[0].id), + GetManyToOneChildByKeyQuery.Data.ManyToOneChild.ParentsItem(parents[2].id), + GetManyToOneChildByKeyQuery.Data.ManyToOneChild.ParentsItem(parents[4].id), + ) + } + + @Test + @Ignore("Write this test once I figure out why the @unique directive fails to compile") + fun oneToOne() { + // This test is just here as a placeholder, to be written later. + } + + @Test + fun manyToMany() = runTest { + val childAKey = connector.insertManyToManyChildA.execute().data.key + val childBKeys = List(3) { connector.insertManyToManyChildB.execute().data.key } + repeat(3) { connector.insertManyToManyParent.execute(childAKey, childBKeys[it]).data.key } + + val queryResult = connector.getManyToManyChildAByKey.execute(childAKey) + + assertThat(queryResult.data.manyToManyChildA?.manyToManyChildBS_via_ManyToManyParent) + .containsExactlyElementsIn( + childBKeys.map { + GetManyToManyChildAByKeyQuery.Data.ManyToManyChildA + .ManyToManyChildBsViaManyToManyParentItem(it.id) + } + ) + } + + @Test + fun manyToOneSelfCustomName() = runTest { + val key1 = connector.insertManyToOneSelfCustomName.execute { ref = null }.data.key + val key2 = connector.insertManyToOneSelfCustomName.execute { ref = key1 }.data.key + val key3 = connector.insertManyToOneSelfCustomName.execute { ref = key2 }.data.key + + val queryResult = connector.getManyToOneSelfCustomNameByKey.execute(key3) + + assertThat(queryResult.data) + .isEqualTo( + GetManyToOneSelfCustomNameByKeyQuery.Data( + GetManyToOneSelfCustomNameByKeyQuery.Data.ManyToOneSelfCustomName( + key3.id, + GetManyToOneSelfCustomNameByKeyQuery.Data.ManyToOneSelfCustomName.Ref(key2.id, key1.id) + ) + ) + ) + } + + @Test + fun manyToOneSelfMatchingName() = runTest { + val key1 = connector.insertManyToOneSelfMatchingName.execute { ref = null }.data.key + val key2 = connector.insertManyToOneSelfMatchingName.execute { ref = key1 }.data.key + val key3 = connector.insertManyToOneSelfMatchingName.execute { ref = key2 }.data.key + + val queryResult = connector.getManyToOneSelfMatchingNameByKey.execute(key3) + + assertThat(queryResult.data) + .isEqualTo( + GetManyToOneSelfMatchingNameByKeyQuery.Data( + GetManyToOneSelfMatchingNameByKeyQuery.Data.ManyToOneSelfMatchingName( + key3.id, + GetManyToOneSelfMatchingNameByKeyQuery.Data.ManyToOneSelfMatchingName + .ManyToOneSelfMatchingName(key2.id, key1.id) + ) + ) + ) + } + + @Test + fun manyToManySelf() = runTest { + val childKeys = List(6) { connector.insertManyToManySelfChild.execute().data.key } + connector.insertManyToManySelfParent.execute(childKeys[0], childKeys[0]).data.key + connector.insertManyToManySelfParent.execute(childKeys[0], childKeys[1]).data.key + connector.insertManyToManySelfParent.execute(childKeys[0], childKeys[2]).data.key + connector.insertManyToManySelfParent.execute(childKeys[1], childKeys[0]).data.key + connector.insertManyToManySelfParent.execute(childKeys[5], childKeys[0]).data.key + connector.insertManyToManySelfParent.execute(childKeys[3], childKeys[4]).data.key + connector.insertManyToManySelfParent.execute(childKeys[5], childKeys[4]).data.key + + val queryResults = childKeys.map { connector.getManyToManySelfChildByKey.execute(it) } + + fun GetManyToManySelfChildByKeyQuery.Data.assertEquals( + keys1: List, + keys2: List + ) { + assertThat( + manyToManySelfChild?.manyToManySelfChildren_via_ManyToManySelfParent_on_child1?.map { + it.id + } + ) + .containsExactlyElementsIn(keys1.map { it.id }) + assertThat( + manyToManySelfChild?.manyToManySelfChildren_via_ManyToManySelfParent_on_child2?.map { + it.id + } + ) + .containsExactlyElementsIn(keys2.map { it.id }) + } + queryResults[0] + .data + .assertEquals( + listOf(childKeys[0], childKeys[1], childKeys[5]), + listOf(childKeys[0], childKeys[1], childKeys[2]) + ) + queryResults[1].data.assertEquals(listOf(childKeys[0]), listOf(childKeys[0])) + queryResults[2].data.assertEquals(listOf(childKeys[0]), emptyList()) + queryResults[3].data.assertEquals(emptyList(), listOf(childKeys[4])) + queryResults[4].data.assertEquals(listOf(childKeys[3], childKeys[5]), emptyList()) + queryResults[5].data.assertEquals(emptyList(), listOf(childKeys[0], childKeys[4])) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationBasicsIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationBasicsIntegrationTest.kt new file mode 100644 index 00000000000..fef31788e2c --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationBasicsIntegrationTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import kotlinx.coroutines.test.* +import org.junit.Test + +class OperationBasicsIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun ref_Variables_ShouldReturnAMutationRefWithTheCorrectProperties() { + val variables = GetFooByIdQuery.Variables("42") + val ref = connector.getFooById.ref(variables) + + assertThat(ref.dataConnect).isSameInstanceAs(connector.dataConnect) + assertThat(ref.variables).isSameInstanceAs(variables) + assertThat(ref.operationName).isEqualTo(GetFooByIdQuery.operationName) + assertThat(ref.dataDeserializer).isSameInstanceAs(GetFooByIdQuery.dataDeserializer) + assertThat(ref.variablesSerializer).isSameInstanceAs(GetFooByIdQuery.variablesSerializer) + } + + @Test + fun ref_Variables_ShouldReturnsADistinctButEqualObjectOnEachInvocation() { + val variables = GetFooByIdQuery.Variables("42") + val ref1 = connector.getFooById.ref(variables) + val ref2 = connector.getFooById.ref(variables) + val ref3 = connector.getFooById.ref(variables) + + assertThat(ref1).isNotSameInstanceAs(ref2) + assertThat(ref1).isNotSameInstanceAs(ref3) + assertThat(ref1).isEqualTo(ref2) + assertThat(ref1).isEqualTo(ref3) + } + + @Test + fun ref_Variables_AlwaysUsesTheExactSameSerializerAndDeserializerInstances() { + // Note: This test is very important because the [QueryManager] uses object identity of the + // variables serializer when fanning out results. + val variables = GetFooByIdQuery.Variables("42") + val connector1 = demoConnectorFactory.newInstance() + val connector2 = demoConnectorFactory.newInstance() + assertThat(connector1).isNotSameInstanceAs(connector2) + + val ref1 = demoConnectorFactory.newInstance().getFooById.ref(variables) + val ref2 = demoConnectorFactory.newInstance().getFooById.ref(variables) + + assertThat(ref1.dataDeserializer).isSameInstanceAs(ref2.dataDeserializer) + assertThat(ref1.variablesSerializer).isSameInstanceAs(ref2.variablesSerializer) + } + + @Test + fun ref_String_ShouldReturnAMutationRefThatIsEqualToRefVariables() { + val variables = GetFooByIdQuery.Variables("42") + val refFromString = connector.getFooById.ref("42") + + val refFromVariables = connector.getFooById.ref(variables) + assertThat(refFromString).isEqualTo(refFromVariables) + } + + @Test + fun equals_ShouldReturnFalseWhenArgumentIsNull() { + assertThat(connector.getFooById.equals(null)).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenArgumentIsAnInstanceOfADifferentClass() { + assertThat(connector.getFooById.equals("foo")).isFalse() + } + + @Test + fun equals_ShouldReturnFalseWhenInvokedOnADistinctObject() { + val instance1 = demoConnectorFactory.newInstance().getFooById + val instance2 = demoConnectorFactory.newInstance().getFooById + assertThat(instance1).isNotSameInstanceAs(instance2) + + assertThat(instance1.equals(instance2)).isFalse() + } + + @Test + @Suppress("USELESS_IS_CHECK") + fun equals_ShouldReturnFalseWhenInvokedOnAnApparentlyEqualButDifferentImplementation() { + val instance = connector.getFooById + val instanceAlternateImpl = GetFooByIdQueryAlternateImpl(instance) + assertThat(instance is GetFooByIdQuery).isTrue() + assertThat(instanceAlternateImpl is GetFooByIdQuery).isTrue() + + assertThat(instance.equals(instanceAlternateImpl)).isFalse() + } + + @Test + fun hashCode_ShouldReturnSameValueOnEachInvocation() { + val hashCode1 = connector.getFooById.hashCode() + val hashCode2 = connector.getFooById.hashCode() + + assertThat(hashCode1).isEqualTo(hashCode2) + } + + @Test + fun hashCode_ShouldReturnDistinctValuesOnDistinctInstances() { + val hashCode1 = demoConnectorFactory.newInstance().getFooById.hashCode() + val hashCode2 = demoConnectorFactory.newInstance().getFooById.hashCode() + + assertThat(hashCode1).isNotEqualTo(hashCode2) + } + + @Test + fun toString_ShouldReturnAStringThatStartsWithClassName() { + val toStringResult = connector.getFooById.toString() + + assertThat(toStringResult).startsWith("GetFooByIdQueryImpl(") + assertThat(toStringResult).endsWith(")") + } + + @Test + fun toString_ShouldReturnAStringThatContainsTheToStringOfTheConnectorInstance() { + val toStringResult = connector.getFooById.toString() + + assertThat(toStringResult).containsWithNonAdjacentText("connector=${connector}") + } + + class GetFooByIdQueryAlternateImpl(delegate: GetFooByIdQuery) : GetFooByIdQuery by delegate +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationExecuteIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationExecuteIntegrationTest.kt new file mode 100644 index 00000000000..3e1259f51e4 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationExecuteIntegrationTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.connectors.demo.testutil.assertWith +import com.google.firebase.dataconnect.testutil.assertThrows +import com.google.firebase.dataconnect.testutil.randomAlphanumericString +import kotlinx.coroutines.test.* +import org.junit.Test + +class OperationExecuteIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun insert_ShouldSucceedIfPrimaryKeyDoesNotExist() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + val bar = randomBar() + + connector.insertFoo.execute(id = id) { this.bar = bar } + + assertWith(connector).thatFooWithId(id).existsWithBar(bar) + } + + @Test + fun insert_ShouldThrowIfPrimaryKeyExists() = runTest { + val id = randomFooId() + val bar = randomBar() + connector.insertFoo.execute(id = id) { this.bar = bar } + assertWith(connector).thatFooWithId(id).exists() + + connector.insertFoo.assertThrows(DataConnectException::class) { + execute(id = id) { this.bar = bar } + } + } + + @Test + fun insert_ShouldContainTheIdInTheResult() = runTest { + val id = randomFooId() + val bar = randomBar() + + val mutationResult = connector.insertFoo.execute(id = id) { this.bar = bar } + + assertThat(mutationResult.data.key).isEqualTo(FooKey(id)) + } + + @Test + fun upsert_ShouldSucceedIfPrimaryKeyDoesNotExist() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + val bar = randomBar() + + connector.upsertFoo.execute(id = id) { this.bar = bar } + + assertWith(connector).thatFooWithId(id).existsWithBar(bar) + } + + @Test + fun upsert_ShouldSucceedIfPrimaryKeyExists() = runTest { + val id = randomFooId() + val bar1 = randomBar() + val bar2 = randomBar() + connector.insertFoo.execute(id = id) { bar = bar1 } + assertWith(connector).thatFooWithId(id).existsWithBar(bar1) + + connector.upsertFoo.execute(id = id) { bar = bar2 } + + assertWith(connector).thatFooWithId(id).existsWithBar(bar2) + } + + @Test + fun upsert_ShouldContainTheIdInTheResultOnInsert() = runTest { + val id = randomFooId() + + val mutationResult = connector.upsertFoo.execute(id = id) { bar = randomBar() } + + assertThat(mutationResult.data.key).isEqualTo(FooKey(id)) + } + + @Test + fun upsert_ShouldContainTheIdInTheResultOnUpdate() = runTest { + val id = randomFooId() + connector.insertFoo.execute(id = id) { bar = randomBar() } + + val mutationResult = connector.upsertFoo.execute(id = id) { bar = randomBar() } + + assertThat(mutationResult.data.key).isEqualTo(FooKey(id)) + } + + @Test + fun delete_ShouldSucceedIfPrimaryKeyDoesNotExist() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + + connector.deleteFoo.execute(id = id) + + assertWith(connector).thatFooWithId(id).doesNotExist() + } + + @Test + fun delete_ShouldSucceedIfPrimaryKeyExists() = runTest { + val id = randomFooId() + val bar = randomBar() + connector.insertFoo.execute(id = id) { this.bar = bar } + assertWith(connector).thatFooWithId(id).existsWithBar(bar) + + connector.deleteFoo.execute(id = id) + + assertWith(connector).thatFooWithId(id).doesNotExist() + } + + @Test + fun delete_ShouldNotContainTheIdInTheResultIfNothingWasDeleted() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + + val mutationResult = connector.deleteFoo.execute(id = id) + + assertThat(mutationResult.data.key).isNull() + } + + @Test + fun delete_ShouldContainTheIdInTheResultIfTheRowWasDeleted() = runTest { + val id = randomFooId() + val bar = randomBar() + connector.insertFoo.execute(id = id) { this.bar = bar } + assertWith(connector).thatFooWithId(id).existsWithBar(bar) + + val mutationResult = connector.deleteFoo.execute(id = id) + + assertThat(mutationResult.data.key).isEqualTo(FooKey(id)) + } + + @Test + fun deleteMany_ShouldSucceedIfNoMatches() = runTest { + val bar = randomBar() + assertWith(connector).thatFoosWithBar(bar).doNotExist() + + connector.deleteFoosByBar.execute(bar = bar) + + assertWith(connector).thatFoosWithBar(bar).doNotExist() + } + + @Test + fun deleteMany_ShouldSucceedIfMultipleMatches() = runTest { + val bar = randomBar() + repeat(5) { connector.insertFoo.execute(id = randomFooId()) { this.bar = bar } } + assertWith(connector).thatFoosWithBar(bar).exist(expectedCount = 5) + + connector.deleteFoosByBar.execute(bar = bar) + + assertWith(connector).thatFoosWithBar(bar).doNotExist() + } + + @Test + fun deleteMany_ShouldReturnZeroIfNoMatches() = runTest { + val bar = randomBar() + assertWith(connector).thatFoosWithBar(bar).doNotExist() + + val mutationResult = connector.deleteFoosByBar.execute(bar = bar) + + assertThat(mutationResult.data.count).isEqualTo(0) + } + + @Test + fun deleteMany_ShouldReturn5If5Matches() = runTest { + val bar = randomBar() + repeat(5) { connector.insertFoo.execute(id = randomFooId()) { this.bar = bar } } + assertWith(connector).thatFoosWithBar(bar).exist(expectedCount = 5) + + val mutationResult = connector.deleteFoosByBar.execute(bar = bar) + + assertThat(mutationResult.data.count).isEqualTo(5) + } + + @Test + fun update_ShouldSucceedIfPrimaryKeyDoesNotExist() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + + connector.updateFoo.execute(id = id) { newBar = randomBar() } + + assertWith(connector).thatFooWithId(id).doesNotExist() + } + + @Test + fun update_ShouldSucceedIfPrimaryKeyExists() = runTest { + val id = randomFooId() + val oldBar = randomBar() + val newBar = randomBar() + connector.insertFoo.execute(id = id) { bar = oldBar } + assertWith(connector).thatFooWithId(id).existsWithBar(oldBar) + + connector.updateFoo.execute(id = id) { this.newBar = newBar } + + assertWith(connector).thatFooWithId(id).existsWithBar(newBar) + } + + @Test + fun update_ShouldNotContainTheIdInTheResultIfNotFound() = runTest { + val id = randomFooId() + assertWith(connector).thatFooWithId(id).doesNotExist() + + val mutationResult = connector.updateFoo.execute(id = id) { newBar = randomBar() } + + assertThat(mutationResult.data.key).isNull() + } + + @Test + fun update_ShouldContainTheIdInTheResultIfFound() = runTest { + val id = randomFooId() + val oldBar = randomBar() + val newBar = randomBar() + connector.insertFoo.execute(id = id) { bar = oldBar } + assertWith(connector).thatFooWithId(id).existsWithBar(oldBar) + + val mutationResult = connector.updateFoo.execute(id = id) { this.newBar = newBar } + + assertThat(mutationResult.data.key).isEqualTo(FooKey(id)) + } + + @Test + fun updateMany_ShouldSucceedIfNoMatches() = runTest { + val oldBar = randomBar() + val newBar = randomBar() + assertWith(connector).thatFoosWithBar(oldBar).doNotExist() + assertWith(connector).thatFoosWithBar(newBar).doNotExist() + + connector.updateFoosByBar.execute { + this.oldBar = oldBar + this.newBar = newBar + } + + assertWith(connector).thatFoosWithBar(oldBar).doNotExist() + assertWith(connector).thatFoosWithBar(newBar).doNotExist() + } + + @Test + fun updateMany_ShouldSucceedIfMultipleMatches() = runTest { + val oldBar = randomBar() + val newBar = randomBar() + repeat(5) { connector.insertFoo.execute(id = randomFooId()) { bar = oldBar } } + assertWith(connector).thatFoosWithBar(oldBar).exist(expectedCount = 5) + assertWith(connector).thatFoosWithBar(newBar).doNotExist() + + connector.updateFoosByBar.execute { + this.oldBar = oldBar + this.newBar = newBar + } + + assertWith(connector).thatFoosWithBar(oldBar).doNotExist() + assertWith(connector).thatFoosWithBar(newBar).exist(expectedCount = 5) + } + + @Test + fun updateMany_ShouldReturnZeroIfNoMatches() = runTest { + val oldBar = randomBar() + assertWith(connector).thatFoosWithBar(oldBar).doNotExist() + + val mutationResult = + connector.updateFoosByBar.execute { + this.oldBar = oldBar + newBar = randomBar() + } + + assertThat(mutationResult.data.count).isEqualTo(0) + } + + @Test + fun updateMany_ShouldReturn5If5Matches() = runTest { + val oldBar = randomBar() + val newBar = randomBar() + repeat(5) { connector.insertFoo.execute(id = randomFooId()) { bar = oldBar } } + assertWith(connector).thatFoosWithBar(oldBar).exist(expectedCount = 5) + assertWith(connector).thatFoosWithBar(newBar).doNotExist() + + val mutationResult = + connector.updateFoosByBar.execute { + this.oldBar = oldBar + this.newBar = newBar + } + + assertThat(mutationResult.data.count).isEqualTo(5) + } + + private fun randomFooId() = randomAlphanumericString(prefix = "FooId", numRandomChars = 20) + private fun randomBar() = randomAlphanumericString(prefix = "Bar", numRandomChars = 20) + + suspend fun DemoConnector.insertFooWithRandomId(): String { + val id = randomFooId() + insertFoo.execute(id = id) { bar = randomBar() } + return id + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OptionalArgumentsIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OptionalArgumentsIntegrationTest.kt new file mode 100644 index 00000000000..4202b9bacc2 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OptionalArgumentsIntegrationTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class OptionalArgumentsIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun optionalStrings() = runTest { + val key = + connector.insertOptionalStrings + .execute(required1 = "aaa", required2 = "bbb") { + this.nullable1 = null + this.nullable2 = "ccc" + } + .data + .key + + val queryResult = connector.getOptionalStringsByKey.execute(key) + + assertThat(queryResult.data.optionalStrings) + .isEqualTo( + GetOptionalStringsByKeyQuery.Data.OptionalStrings( + required1 = "aaa", + required2 = "bbb", + nullable1 = null, + nullable2 = "ccc", + nullable3 = null, + nullableWithSchemaDefault = "pb429m" + ) + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ScalarVariablesAndDataIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ScalarVariablesAndDataIntegrationTest.kt new file mode 100644 index 00000000000..6deb4a49906 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ScalarVariablesAndDataIntegrationTest.kt @@ -0,0 +1,1111 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.testutil.MAX_SAFE_INTEGER +import java.util.UUID +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +class ScalarVariablesAndDataIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun insertStringVariants() = runTest { + val key = + connector.insertStringVariants + .execute( + nonNullWithNonEmptyValue = "some non-empty value for a *non*-nullable field", + nonNullWithEmptyValue = "", + ) { + nullableWithNullValue = null + nullableWithNonNullValue = "some non-empty value for a *nullable* field" + nullableWithEmptyValue = "" + } + .data + .key + + val queryResult = connector.getStringVariantsByKey.execute(key) + assertThat(queryResult.data.stringVariants) + .isEqualTo( + GetStringVariantsByKeyQuery.Data.StringVariants( + nonNullWithNonEmptyValue = "some non-empty value for a *non*-nullable field", + nonNullWithEmptyValue = "", + nullableWithNullValue = null, + nullableWithNonNullValue = "some non-empty value for a *nullable* field", + nullableWithEmptyValue = "", + ) + ) + } + + @Test + fun insertStringVariantsWithDefaultValues() = runTest { + val key = connector.insertStringVariantsWithHardcodedDefaults.execute {}.data.key + + val queryResult = connector.getStringVariantsByKey.execute(key) + assertThat(queryResult.data.stringVariants) + .isEqualTo( + GetStringVariantsByKeyQuery.Data.StringVariants( + nonNullWithNonEmptyValue = "pfnk98yqqs", + nonNullWithEmptyValue = "", + nullableWithNullValue = null, + nullableWithNonNullValue = "af8k72s98t", + nullableWithEmptyValue = "", + ) + ) + } + + @Test + fun updateStringVariantsToNonNullValues() = runTest { + val key = + connector.insertStringVariants + .execute( + nonNullWithNonEmptyValue = "d94gpbmwf6", + nonNullWithEmptyValue = "", + ) { + nullableWithNullValue = null + nullableWithNonNullValue = "wcwkenscxd" + nullableWithEmptyValue = "" + } + .data + .key + + connector.updateStringVariantsByKey.execute(key) { + nonNullWithNonEmptyValue = "" + nonNullWithEmptyValue = "q3vvetx52x" + nullableWithNullValue = "d54kpn29pb" + nullableWithNonNullValue = "sfbm8epy94" + nullableWithEmptyValue = "pxhz7awrz9" + } + + val queryResult = connector.getStringVariantsByKey.execute(key) + assertThat(queryResult.data.stringVariants) + .isEqualTo( + GetStringVariantsByKeyQuery.Data.StringVariants( + nonNullWithNonEmptyValue = "", + nonNullWithEmptyValue = "q3vvetx52x", + nullableWithNullValue = "d54kpn29pb", + nullableWithNonNullValue = "sfbm8epy94", + nullableWithEmptyValue = "pxhz7awrz9", + ) + ) + } + + @Test + fun updateStringVariantsToNullValues() = runTest { + val key = + connector.insertStringVariants + .execute( + nonNullWithNonEmptyValue = "pqb9vc52pp", + nonNullWithEmptyValue = "", + ) { + nullableWithNullValue = null + nullableWithNonNullValue = "xyka3gsmad" + nullableWithEmptyValue = "" + } + .data + .key + + connector.updateStringVariantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithNonNullValue = null + nullableWithEmptyValue = null + } + + val queryResult = connector.getStringVariantsByKey.execute(key) + assertThat(queryResult.data.stringVariants) + .isEqualTo( + GetStringVariantsByKeyQuery.Data.StringVariants( + nonNullWithNonEmptyValue = "pqb9vc52pp", + nonNullWithEmptyValue = "", + nullableWithNullValue = null, + nullableWithNonNullValue = null, + nullableWithEmptyValue = null, + ) + ) + } + + @Test + fun updateStringVariantsToUndefinedValues() = runTest { + val key = + connector.insertStringVariants + .execute( + nonNullWithNonEmptyValue = "6t25b9jyxc", + nonNullWithEmptyValue = "", + ) { + nullableWithNullValue = null + nullableWithNonNullValue = "kybbsaxpkw" + nullableWithEmptyValue = "" + } + .data + .key + + connector.updateStringVariantsByKey.execute(key) {} + + val queryResult = connector.getStringVariantsByKey.execute(key) + assertThat(queryResult.data.stringVariants) + .isEqualTo( + GetStringVariantsByKeyQuery.Data.StringVariants( + nonNullWithNonEmptyValue = "6t25b9jyxc", + nonNullWithEmptyValue = "", + nullableWithNullValue = null, + nullableWithNonNullValue = "kybbsaxpkw", + nullableWithEmptyValue = "", + ) + ) + } + + @Test + fun insertIntVariants() = runTest { + val key = + connector.insertIntVariants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 24242424 + nullableWithNegativeValue = -24242424 + nullableWithMaxValue = Int.MAX_VALUE + nullableWithMinValue = Int.MIN_VALUE + } + .data + .key + + val queryResult = connector.getIntVariantsByKey.execute(key) + assertThat(queryResult.data.intVariants) + .isEqualTo( + GetIntVariantsByKeyQuery.Data.IntVariants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 24242424, + nullableWithNegativeValue = -24242424, + nullableWithMaxValue = Int.MAX_VALUE, + nullableWithMinValue = Int.MIN_VALUE, + ) + ) + } + + @Test + fun insertIntVariantsWithDefaultValues() = runTest { + val key = connector.insertIntVariantsWithHardcodedDefaults.execute {}.data.key + + val queryResult = connector.getIntVariantsByKey.execute(key) + assertThat(queryResult.data.intVariants) + .isEqualTo( + GetIntVariantsByKeyQuery.Data.IntVariants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 819425, + nonNullWithNegativeValue = -435970, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 635166, + nullableWithNegativeValue = -171993, + nullableWithMaxValue = Int.MAX_VALUE, + nullableWithMinValue = Int.MIN_VALUE, + ) + ) + } + + @Test + fun updateIntVariantsToNonNullValues() = runTest { + val key = + connector.insertIntVariants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 24242424 + nullableWithNegativeValue = -24242424 + nullableWithMaxValue = Int.MAX_VALUE + nullableWithMinValue = Int.MIN_VALUE + } + .data + .key + + connector.updateIntVariantsByKey.execute(key) { + nonNullWithZeroValue = 7878 + nonNullWithPositiveValue = Int.MAX_VALUE + nonNullWithNegativeValue = Int.MIN_VALUE + nonNullWithMaxValue = 1 + nonNullWithMinValue = -1 + nullableWithNullValue = 8787 + nullableWithZeroValue = 0 + nullableWithPositiveValue = Int.MAX_VALUE + nullableWithNegativeValue = Int.MIN_VALUE + nullableWithMaxValue = 1 + nullableWithMinValue = -1 + } + + val queryResult = connector.getIntVariantsByKey.execute(key) + assertThat(queryResult.data.intVariants) + .isEqualTo( + GetIntVariantsByKeyQuery.Data.IntVariants( + nonNullWithZeroValue = 7878, + nonNullWithPositiveValue = Int.MAX_VALUE, + nonNullWithNegativeValue = Int.MIN_VALUE, + nonNullWithMaxValue = 1, + nonNullWithMinValue = -1, + nullableWithNullValue = 8787, + nullableWithZeroValue = 0, + nullableWithPositiveValue = Int.MAX_VALUE, + nullableWithNegativeValue = Int.MIN_VALUE, + nullableWithMaxValue = 1, + nullableWithMinValue = -1, + ) + ) + } + + @Test + fun updateIntVariantsToNullValues() = runTest { + val key = + connector.insertIntVariants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 24242424 + nullableWithNegativeValue = -24242424 + nullableWithMaxValue = Int.MAX_VALUE + nullableWithMinValue = Int.MIN_VALUE + } + .data + .key + + connector.updateIntVariantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithZeroValue = null + nullableWithPositiveValue = null + nullableWithNegativeValue = null + nullableWithMaxValue = null + nullableWithMinValue = null + } + + val queryResult = connector.getIntVariantsByKey.execute(key) + assertThat(queryResult.data.intVariants) + .isEqualTo( + GetIntVariantsByKeyQuery.Data.IntVariants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = null, + nullableWithPositiveValue = null, + nullableWithNegativeValue = null, + nullableWithMaxValue = null, + nullableWithMinValue = null, + ) + ) + } + + @Test + fun updateIntVariantsToUndefinedValues() = runTest { + val key = + connector.insertIntVariants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 24242424 + nullableWithNegativeValue = -24242424 + nullableWithMaxValue = Int.MAX_VALUE + nullableWithMinValue = Int.MIN_VALUE + } + .data + .key + + connector.updateIntVariantsByKey.execute(key) {} + + val queryResult = connector.getIntVariantsByKey.execute(key) + assertThat(queryResult.data.intVariants) + .isEqualTo( + GetIntVariantsByKeyQuery.Data.IntVariants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 42424242, + nonNullWithNegativeValue = -42424242, + nonNullWithMaxValue = Int.MAX_VALUE, + nonNullWithMinValue = Int.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 24242424, + nullableWithNegativeValue = -24242424, + nullableWithMaxValue = Int.MAX_VALUE, + nullableWithMinValue = Int.MIN_VALUE, + ) + ) + } + + @Test + fun insertFloatVariants() = runTest { + val key = + connector.insertFloatVariants + .execute( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = -0.0, + nonNullWithPositiveValue = 123.456, + nonNullWithNegativeValue = -987.654, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0.0 + nullableWithNegativeZeroValue = 0.0 + nullableWithPositiveValue = 789.012 + nullableWithNegativeValue = -321.098 + nullableWithMaxValue = Double.MAX_VALUE + nullableWithMinValue = Double.MIN_VALUE + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER + } + .data + .key + + val queryResult = connector.getFloatVariantsByKey.execute(key) + assertThat(queryResult.data.floatVariants) + .isEqualTo( + GetFloatVariantsByKeyQuery.Data.FloatVariants( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = 0.0, + nonNullWithPositiveValue = 123.456, + nonNullWithNegativeValue = -987.654, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + nullableWithNullValue = null, + nullableWithZeroValue = 0.0, + nullableWithNegativeZeroValue = 0.0, + nullableWithPositiveValue = 789.012, + nullableWithNegativeValue = -321.098, + nullableWithMaxValue = Double.MAX_VALUE, + nullableWithMinValue = Double.MIN_VALUE, + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) + ) + } + + @Test + fun insertFloatVariantsWithDefaultValues() = runTest { + val key = connector.insertFloatVariantsWithHardcodedDefaults.execute {}.data.key + + val queryResult = connector.getFloatVariantsByKey.execute(key) + assertThat(queryResult.data.floatVariants) + .isEqualTo( + GetFloatVariantsByKeyQuery.Data.FloatVariants( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = 0.0, + nonNullWithPositiveValue = 750.452, + nonNullWithNegativeValue = -598.351, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + nullableWithNullValue = null, + nullableWithZeroValue = 0.0, + nullableWithNegativeZeroValue = 0.0, + nullableWithPositiveValue = 597.650, + nullableWithNegativeValue = -181.366, + nullableWithMaxValue = Double.MAX_VALUE, + nullableWithMinValue = Double.MIN_VALUE, + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) + ) + } + + @Test + fun updateFloatVariantsToNonNullValues() = runTest { + val key = + connector.insertFloatVariants + .execute( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = -0.0, + nonNullWithPositiveValue = 662.096, + nonNullWithNegativeValue = -817.024, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0.0 + nullableWithNegativeZeroValue = 0.0 + nullableWithPositiveValue = 990.273 + nullableWithNegativeValue = -383.185 + nullableWithMaxValue = Double.MAX_VALUE + nullableWithMinValue = Double.MIN_VALUE + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER + } + .data + .key + + connector.updateFloatVariantsByKey.execute(key) { + nonNullWithZeroValue = Double.MAX_VALUE + nonNullWithNegativeZeroValue = Double.MIN_VALUE + nonNullWithPositiveValue = MAX_SAFE_INTEGER + nonNullWithNegativeValue = -0.0 + nonNullWithMaxValue = -270.396 + nonNullWithMinValue = 470.563 + nonNullWithMaxSafeIntegerValue = 0.0 + nullableWithNullValue = 607.386 + nullableWithZeroValue = Double.MIN_VALUE + nullableWithNegativeZeroValue = MAX_SAFE_INTEGER + nullableWithPositiveValue = -0.0 + nullableWithNegativeValue = MAX_SAFE_INTEGER + nullableWithMaxValue = -930.342 + nullableWithMinValue = 563.398 + nullableWithMaxSafeIntegerValue = 0.0 + } + + val queryResult = connector.getFloatVariantsByKey.execute(key) + assertThat(queryResult.data.floatVariants) + .isEqualTo( + GetFloatVariantsByKeyQuery.Data.FloatVariants( + nonNullWithZeroValue = Double.MAX_VALUE, + nonNullWithNegativeZeroValue = Double.MIN_VALUE, + nonNullWithPositiveValue = MAX_SAFE_INTEGER, + nonNullWithNegativeValue = 0.0, + nonNullWithMaxValue = -270.396, + nonNullWithMinValue = 470.563, + nonNullWithMaxSafeIntegerValue = 0.0, + nullableWithNullValue = 607.386, + nullableWithZeroValue = Double.MIN_VALUE, + nullableWithNegativeZeroValue = MAX_SAFE_INTEGER, + nullableWithPositiveValue = 0.0, + nullableWithNegativeValue = MAX_SAFE_INTEGER, + nullableWithMaxValue = -930.342, + nullableWithMinValue = 563.398, + nullableWithMaxSafeIntegerValue = 0.0, + ) + ) + } + + @Test + fun updateFloatVariantsToNullValues() = runTest { + val key = + connector.insertFloatVariants + .execute( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = -0.0, + nonNullWithPositiveValue = 225.954, + nonNullWithNegativeValue = -432.366, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0.0 + nullableWithNegativeZeroValue = 0.0 + nullableWithPositiveValue = 446.040 + nullableWithNegativeValue = -573.104 + nullableWithMaxValue = Double.MAX_VALUE + nullableWithMinValue = Double.MIN_VALUE + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER + } + .data + .key + + connector.updateFloatVariantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithZeroValue = null + nullableWithNegativeZeroValue = null + nullableWithPositiveValue = null + nullableWithNegativeValue = null + nullableWithMaxValue = null + nullableWithMinValue = null + nullableWithMaxSafeIntegerValue = null + } + + val queryResult = connector.getFloatVariantsByKey.execute(key) + assertThat(queryResult.data.floatVariants) + .isEqualTo( + GetFloatVariantsByKeyQuery.Data.FloatVariants( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = 0.0, + nonNullWithPositiveValue = 225.954, + nonNullWithNegativeValue = -432.366, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + nullableWithNullValue = null, + nullableWithZeroValue = null, + nullableWithNegativeZeroValue = null, + nullableWithPositiveValue = null, + nullableWithNegativeValue = null, + nullableWithMaxValue = null, + nullableWithMinValue = null, + nullableWithMaxSafeIntegerValue = null, + ) + ) + } + + @Test + fun updateFloatVariantsToUndefinedValues() = runTest { + val key = + connector.insertFloatVariants + .execute( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = -0.0, + nonNullWithPositiveValue = 969.803, + nonNullWithNegativeValue = -377.693, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0.0 + nullableWithNegativeZeroValue = 0.0 + nullableWithPositiveValue = 789.821 + nullableWithNegativeValue = -498.776 + nullableWithMaxValue = Double.MAX_VALUE + nullableWithMinValue = Double.MIN_VALUE + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER + } + .data + .key + + connector.updateFloatVariantsByKey.execute(key) {} + + val queryResult = connector.getFloatVariantsByKey.execute(key) + assertThat(queryResult.data.floatVariants) + .isEqualTo( + GetFloatVariantsByKeyQuery.Data.FloatVariants( + nonNullWithZeroValue = 0.0, + nonNullWithNegativeZeroValue = 0.0, + nonNullWithPositiveValue = 969.803, + nonNullWithNegativeValue = -377.693, + nonNullWithMaxValue = Double.MAX_VALUE, + nonNullWithMinValue = Double.MIN_VALUE, + nonNullWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + nullableWithNullValue = null, + nullableWithZeroValue = 0.0, + nullableWithNegativeZeroValue = 0.0, + nullableWithPositiveValue = 789.821, + nullableWithNegativeValue = -498.776, + nullableWithMaxValue = Double.MAX_VALUE, + nullableWithMinValue = Double.MIN_VALUE, + nullableWithMaxSafeIntegerValue = MAX_SAFE_INTEGER, + ) + ) + } + + @Test + fun insertBooleanVariants() = runTest { + val key = + connector.insertBooleanVariants + .execute( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + ) { + nullableWithNullValue = null + nullableWithTrueValue = true + nullableWithFalseValue = false + } + .data + .key + + val queryResult = connector.getBooleanVariantsByKey.execute(key) + assertThat(queryResult.data.booleanVariants) + .isEqualTo( + GetBooleanVariantsByKeyQuery.Data.BooleanVariants( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + nullableWithNullValue = null, + nullableWithTrueValue = true, + nullableWithFalseValue = false, + ) + ) + } + + @Test + fun insertBooleanVariantsWithDefaultValues() = runTest { + val key = connector.insertBooleanVariantsWithHardcodedDefaults.execute {}.data.key + + val queryResult = connector.getBooleanVariantsByKey.execute(key) + assertThat(queryResult.data.booleanVariants) + .isEqualTo( + GetBooleanVariantsByKeyQuery.Data.BooleanVariants( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + nullableWithNullValue = null, + nullableWithTrueValue = true, + nullableWithFalseValue = false, + ) + ) + } + + @Test + fun updateBooleanVariantsToNonNullValues() = runTest { + val key = + connector.insertBooleanVariants + .execute( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + ) { + nullableWithNullValue = null + nullableWithTrueValue = true + nullableWithFalseValue = false + } + .data + .key + + connector.updateBooleanVariantsByKey.execute(key) { + nonNullWithTrueValue = false + nonNullWithFalseValue = true + nullableWithNullValue = true + nullableWithTrueValue = false + nullableWithFalseValue = true + } + + val queryResult = connector.getBooleanVariantsByKey.execute(key) + assertThat(queryResult.data.booleanVariants) + .isEqualTo( + GetBooleanVariantsByKeyQuery.Data.BooleanVariants( + nonNullWithTrueValue = false, + nonNullWithFalseValue = true, + nullableWithNullValue = true, + nullableWithTrueValue = false, + nullableWithFalseValue = true, + ) + ) + } + + @Test + fun updateBooleanVariantsToNullValues() = runTest { + val key = + connector.insertBooleanVariants + .execute( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + ) { + nullableWithNullValue = null + nullableWithTrueValue = true + nullableWithFalseValue = false + } + .data + .key + + connector.updateBooleanVariantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithTrueValue = null + nullableWithFalseValue = null + } + + val queryResult = connector.getBooleanVariantsByKey.execute(key) + assertThat(queryResult.data.booleanVariants) + .isEqualTo( + GetBooleanVariantsByKeyQuery.Data.BooleanVariants( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + nullableWithNullValue = null, + nullableWithTrueValue = null, + nullableWithFalseValue = null, + ) + ) + } + + @Test + fun updateBooleanVariantsToUndefinedValues() = runTest { + val key = + connector.insertBooleanVariants + .execute( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + ) { + nullableWithNullValue = null + nullableWithTrueValue = true + nullableWithFalseValue = false + } + .data + .key + + connector.updateBooleanVariantsByKey.execute(key) {} + + val queryResult = connector.getBooleanVariantsByKey.execute(key) + assertThat(queryResult.data.booleanVariants) + .isEqualTo( + GetBooleanVariantsByKeyQuery.Data.BooleanVariants( + nonNullWithTrueValue = true, + nonNullWithFalseValue = false, + nullableWithNullValue = null, + nullableWithTrueValue = true, + nullableWithFalseValue = false, + ) + ) + } + + @Test + fun insertInt64Variants() = runTest { + val key = + connector.insertInt64variants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 4242424242424242, + nonNullWithNegativeValue = -4242424242424242, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 2424242424242424 + nullableWithNegativeValue = -2424242424242424 + nullableWithMaxValue = Long.MAX_VALUE + nullableWithMinValue = Long.MIN_VALUE + } + .data + .key + + val queryResult = connector.getInt64variantsByKey.execute(key) + assertThat(queryResult.data.int64Variants) + .isEqualTo( + GetInt64variantsByKeyQuery.Data.Int64variants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 4242424242424242, + nonNullWithNegativeValue = -4242424242424242, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 2424242424242424, + nullableWithNegativeValue = -2424242424242424, + nullableWithMaxValue = Long.MAX_VALUE, + nullableWithMinValue = Long.MIN_VALUE, + ) + ) + } + + @Test + fun insertInt64VariantsWithDefaultValues() = runTest { + val key = connector.insertInt64variantsWithHardcodedDefaults.execute {}.data.key + + val queryResult = connector.getInt64variantsByKey.execute(key) + assertThat(queryResult.data.int64Variants) + .isEqualTo( + GetInt64variantsByKeyQuery.Data.Int64variants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 8140262498000722655, + nonNullWithNegativeValue = -6722404680598014256, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 2623421399624774761, + nullableWithNegativeValue = -1400927531111898547, + nullableWithMaxValue = Long.MAX_VALUE, + nullableWithMinValue = Long.MIN_VALUE, + ) + ) + } + + @Test + fun updateInt64VariantsToNonNullValues() = runTest { + val key = + connector.insertInt64variants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 4242424242424242, + nonNullWithNegativeValue = -4242424242424242, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 2424242424242424 + nullableWithNegativeValue = -2424242424242424 + nullableWithMaxValue = Long.MAX_VALUE + nullableWithMinValue = Long.MIN_VALUE + } + .data + .key + + connector.updateInt64variantsByKey.execute(key) { + nonNullWithZeroValue = Long.MAX_VALUE + nonNullWithPositiveValue = Long.MIN_VALUE + nonNullWithNegativeValue = 0 + nonNullWithMaxValue = 6252443364575076407 + nonNullWithMinValue = -2729456791747763772 + nullableWithNullValue = Long.MIN_VALUE + nullableWithZeroValue = Long.MAX_VALUE + nullableWithPositiveValue = -8687725805487568442 + nullableWithNegativeValue = 2353423753564688753 + nullableWithMaxValue = 0 + nullableWithMinValue = 1138055334163106400 + } + + val queryResult = connector.getInt64variantsByKey.execute(key) + assertThat(queryResult.data.int64Variants) + .isEqualTo( + GetInt64variantsByKeyQuery.Data.Int64variants( + nonNullWithZeroValue = Long.MAX_VALUE, + nonNullWithPositiveValue = Long.MIN_VALUE, + nonNullWithNegativeValue = 0, + nonNullWithMaxValue = 6252443364575076407, + nonNullWithMinValue = -2729456791747763772, + nullableWithNullValue = Long.MIN_VALUE, + nullableWithZeroValue = Long.MAX_VALUE, + nullableWithPositiveValue = -8687725805487568442, + nullableWithNegativeValue = 2353423753564688753, + nullableWithMaxValue = 0, + nullableWithMinValue = 1138055334163106400, + ) + ) + } + + @Test + fun updateInt64VariantsToNullValues() = runTest { + val key = + connector.insertInt64variants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 6015655135498983208, + nonNullWithNegativeValue = -6239673548840053697, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 2139268131023575155 + nullableWithNegativeValue = -7753368718652189037 + nullableWithMaxValue = Long.MAX_VALUE + nullableWithMinValue = Long.MIN_VALUE + } + .data + .key + + connector.updateInt64variantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithZeroValue = null + nullableWithPositiveValue = null + nullableWithNegativeValue = null + nullableWithMaxValue = null + nullableWithMinValue = null + } + + val queryResult = connector.getInt64variantsByKey.execute(key) + assertThat(queryResult.data.int64Variants) + .isEqualTo( + GetInt64variantsByKeyQuery.Data.Int64variants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 6015655135498983208, + nonNullWithNegativeValue = -6239673548840053697, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = null, + nullableWithPositiveValue = null, + nullableWithNegativeValue = null, + nullableWithMaxValue = null, + nullableWithMinValue = null, + ) + ) + } + + @Test + fun updateInt64VariantsToUndefinedValues() = runTest { + val key = + connector.insertInt64variants + .execute( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 6701682660019975832, + nonNullWithNegativeValue = -4478250605910359747, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + ) { + nullableWithNullValue = null + nullableWithZeroValue = 0 + nullableWithPositiveValue = 5813549730210600934 + nullableWithNegativeValue = -8226376165047801337 + nullableWithMaxValue = Long.MAX_VALUE + nullableWithMinValue = Long.MIN_VALUE + } + .data + .key + + connector.updateInt64variantsByKey.execute(key) {} + + val queryResult = connector.getInt64variantsByKey.execute(key) + assertThat(queryResult.data.int64Variants) + .isEqualTo( + GetInt64variantsByKeyQuery.Data.Int64variants( + nonNullWithZeroValue = 0, + nonNullWithPositiveValue = 6701682660019975832, + nonNullWithNegativeValue = -4478250605910359747, + nonNullWithMaxValue = Long.MAX_VALUE, + nonNullWithMinValue = Long.MIN_VALUE, + nullableWithNullValue = null, + nullableWithZeroValue = 0, + nullableWithPositiveValue = 5813549730210600934, + nullableWithNegativeValue = -8226376165047801337, + nullableWithMaxValue = Long.MAX_VALUE, + nullableWithMinValue = Long.MIN_VALUE, + ) + ) + } + + @Test + fun insertUUIDVariants() = runTest { + val key = + connector.insertUuidVariants + .execute( + nonNullValue = UUID.fromString("9ceda52f-18a1-431b-b9f7-89b674ca4bee"), + ) { + nullableWithNullValue = null + nullableWithNonNullValue = UUID.fromString("7ca7c62a-c551-4cb9-8f86-0a2ce3d68b72") + } + .data + .key + + val queryResult = connector.getUuidVariantsByKey.execute(key) + assertThat(queryResult.data.uUIDVariants) + .isEqualTo( + GetUuidVariantsByKeyQuery.Data.UUidVariants( + nonNullValue = UUID.fromString("9ceda52f-18a1-431b-b9f7-89b674ca4bee"), + nullableWithNullValue = null, + nullableWithNonNullValue = UUID.fromString("7ca7c62a-c551-4cb9-8f86-0a2ce3d68b72"), + ) + ) + } + + @Test + @Ignore("TODO(b/341070491) Re-enable this test once default values for UUID variables is fixed") + fun insertUUIDVariantsWithDefaultValues() = runTest { + // TODO(b/341070491) Update the definition of the "InsertUUIDVariantsWithHardcodedDefaults" + // mutation in GraphQL and change .execute() to .execute{}. + val key = connector.insertUuidVariantsWithHardcodedDefaults.execute().data.key + + val queryResult = connector.getUuidVariantsByKey.execute(key) + assertThat(queryResult.data.uUIDVariants) + .isEqualTo( + GetUuidVariantsByKeyQuery.Data.UUidVariants( + nonNullValue = UUID.fromString("66576fdc-1a35-4b59-8c8b-d3beb65956ca"), + nullableWithNullValue = null, + nullableWithNonNullValue = UUID.fromString("59ab3886-8b84-4233-a5e6-da58c0e8b97d"), + ) + ) + } + + @Test + fun updateUUIDVariantsToNonNullValues() = runTest { + val key = + connector.insertUuidVariants + .execute( + nonNullValue = UUID.fromString("e0e9539c-5723-4063-b490-20b0f28c82fc"), + ) { + nullableWithNullValue = null + nullableWithNonNullValue = UUID.fromString("c198ecf2-8de5-438f-8b9e-4d07e07d2a7e") + } + .data + .key + + connector.updateUuidVariantsByKey.execute(key) { + nonNullValue = UUID.fromString("a4d3f3cb-f88a-4aeb-9440-b446780e3f1f") + nullableWithNullValue = UUID.fromString("e6fda23b-26ab-422c-a461-75bf2cd08775") + nullableWithNonNullValue = UUID.fromString("22d122a7-45c6-4f7a-ba0b-bf00aa47c77a") + } + + val queryResult = connector.getUuidVariantsByKey.execute(key) + assertThat(queryResult.data.uUIDVariants) + .isEqualTo( + GetUuidVariantsByKeyQuery.Data.UUidVariants( + nonNullValue = UUID.fromString("a4d3f3cb-f88a-4aeb-9440-b446780e3f1f"), + nullableWithNullValue = UUID.fromString("e6fda23b-26ab-422c-a461-75bf2cd08775"), + nullableWithNonNullValue = UUID.fromString("22d122a7-45c6-4f7a-ba0b-bf00aa47c77a"), + ) + ) + } + + @Test + fun updateUUIDVariantsToNullValues() = runTest { + val key = + connector.insertUuidVariants + .execute( + nonNullValue = UUID.fromString("a319232e-ef2b-4bb2-96e7-c31893914b77"), + ) { + nullableWithNullValue = null + nullableWithNonNullValue = UUID.fromString("95ba2d8e-7908-4b60-999c-7c292616c920") + } + .data + .key + + connector.updateUuidVariantsByKey.execute(key) { + nullableWithNullValue = null + nullableWithNonNullValue = null + } + + val queryResult = connector.getUuidVariantsByKey.execute(key) + assertThat(queryResult.data.uUIDVariants) + .isEqualTo( + GetUuidVariantsByKeyQuery.Data.UUidVariants( + nonNullValue = UUID.fromString("a319232e-ef2b-4bb2-96e7-c31893914b77"), + nullableWithNullValue = null, + nullableWithNonNullValue = null, + ) + ) + } + + @Test + fun updateUUIDVariantsToUndefinedValues() = runTest { + val key = + connector.insertUuidVariants + .execute( + nonNullValue = UUID.fromString("c72c5a7c-f179-48a5-83fb-171a148b0192"), + ) { + nullableWithNullValue = null + nullableWithNonNullValue = UUID.fromString("dd55c183-616a-4bc8-a4e0-2a32101450d7") + } + .data + .key + + connector.updateUuidVariantsByKey.execute(key) {} + + val queryResult = connector.getUuidVariantsByKey.execute(key) + assertThat(queryResult.data.uUIDVariants) + .isEqualTo( + GetUuidVariantsByKeyQuery.Data.UUidVariants( + nonNullValue = UUID.fromString("c72c5a7c-f179-48a5-83fb-171a148b0192"), + nullableWithNullValue = null, + nullableWithNonNullValue = UUID.fromString("dd55c183-616a-4bc8-a4e0-2a32101450d7"), + ) + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/SyntheticIdIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/SyntheticIdIntegrationTest.kt new file mode 100644 index 00000000000..29a43525b2a --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/SyntheticIdIntegrationTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.testutil.* +import com.google.firebase.dataconnect.testutil.* +import kotlinx.coroutines.test.* +import org.junit.Test + +class SyntheticIdIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun syntheticIdShouldBeGeneratedIfNoExplicitlySpecifiedInGQL() = runTest { + val value = randomAlphanumericString() + + val id = connector.insertSyntheticId.execute(value).data.key.id + + val queryResult = connector.getSyntheticIdById.execute(id) + assertThat(queryResult.data.syntheticId) + .isEqualTo(GetSyntheticIdByIdQuery.Data.SyntheticId(id = id, value = value)) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/TimestampScalarIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/TimestampScalarIntegrationTest.kt new file mode 100644 index 00000000000..68af80a8add --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/TimestampScalarIntegrationTest.kt @@ -0,0 +1,898 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Timestamp +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase +import com.google.firebase.dataconnect.generated.GeneratedMutation +import com.google.firebase.dataconnect.generated.GeneratedQuery +import com.google.firebase.dataconnect.testutil.MAX_TIMESTAMP +import com.google.firebase.dataconnect.testutil.MIN_TIMESTAMP +import com.google.firebase.dataconnect.testutil.ZERO_TIMESTAMP +import com.google.firebase.dataconnect.testutil.assertThrows +import com.google.firebase.dataconnect.testutil.executeWithEmptyVariables +import com.google.firebase.dataconnect.testutil.randomTimestamp +import com.google.firebase.dataconnect.testutil.timestampFromUTCDateAndTime +import com.google.firebase.dataconnect.testutil.withDataDeserializer +import com.google.firebase.dataconnect.testutil.withMicrosecondPrecision +import com.google.firebase.dataconnect.testutil.withVariablesSerializer +import kotlin.random.Random +import kotlin.random.nextInt +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Ignore +import org.junit.Test + +class TimestampScalarIntegrationTest : DemoConnectorIntegrationTestBase() { + + @Test + fun insertTypicalValueForNonNullTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(2361, 1, 16, 2, 36, 25, 253177157) + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2361-01-16T02:36:25.253177Z") + } + + @Test + fun insertMaxValueForNonNullTimestampField() = runTest { + val key = connector.insertNonNullTimestamp.execute(MIN_TIMESTAMP).data.key + assertNonNullTimestampByKeyEquals(key, "1583-01-01T00:00:00.000000Z") + } + + @Test + fun insertMinValueForNonNullTimestampField() = runTest { + val key = connector.insertNonNullTimestamp.execute(MAX_TIMESTAMP).data.key + assertNonNullTimestampByKeyEquals(key, "9999-12-31T23:59:59.999999Z") + } + + @Test + fun insertTimestampWithSingleDigitsForNonNullTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(7513, 1, 2, 3, 4, 5, 6000) + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "7513-01-02T03:04:05.000006Z") + } + + @Test + fun insertTimestampWithAllDigitsForNonNullTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(8623, 10, 11, 12, 13, 14, 123456789) + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "8623-10-11T12:13:14.123456Z") + } + + @Test + fun insertTimestampWithNoNanosecondsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.000000Z") + } + + @Test + fun insertTimestampWithZeroNanosecondsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.000000000Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.000000Z") + } + + @Test + fun insertTimestampWith1NanosecondsDigitForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.100000Z") + } + + @Test + fun insertTimestampWith2NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.120000Z") + } + + @Test + fun insertTimestampWith3NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123000Z") + } + + @Test + fun insertTimestampWith4NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1234Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123400Z") + } + + @Test + fun insertTimestampWith5NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12345Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123450Z") + } + + @Test + fun insertTimestampWith6NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith7NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1234567Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith8NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12345678Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith9NanosecondsDigitsForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789Z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWithPlus0TimeZoneOffsetForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789+00:00" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWithPositiveNonZeroTimeZoneOffsetForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789+01:23" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T11:22:56.123456Z") + } + + @Test + fun insertTimestampWithNegativeNonZeroTimeZoneOffsetForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789-01:23" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T14:08:56.123456Z") + } + + @Test + @Ignore("TODO(b/341984878): Re-enable this test once the backend accepts leap seconds") + fun insertTimestampWithLeapSecondStringForNonNullTimestampField() = runTest { + val timestamp = "1990-12-31T23:59:60Z" // From RFC3339 section 5.8 + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "1990-12-31T23:59:60.000000Z") + } + + @Test + fun insertTimestampWithLeapSecondDateForNonNullTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(1990, 12, 31, 23, 59, 60, 0) + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, timestamp) + } + + @Test + @Ignore("TODO(b/341984878): Re-enable this test once the backend accepts lowercase T and Z") + fun insertTimestampWithLowercaseTandZForNonNullTimestampField() = runTest { + val timestamp = "2024-05-18t12:45:56.123456789z" + val key = connector.insertNonNullTimestamp.executeWithStringVariables(timestamp).data.key + assertNonNullTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertNoVariablesForNonNullTimestampFieldsWithDefaults() = runTest { + val key = connector.insertNonNullTimestampsWithDefaults.execute {}.data.key + val queryResult = connector.getNonNullTimestampsWithDefaultsByKey.execute(key) + + // Since we can't know the exact value of `request.time` just make sure that the exact same + // value is used for both fields to which it is set. + val expectedRequestTime = queryResult.data.nonNullTimestampsWithDefaults!!.requestTime1 + + assertThat( + queryResult.equals( + GetNonNullTimestampsWithDefaultsByKeyQuery.Data( + GetNonNullTimestampsWithDefaultsByKeyQuery.Data.NonNullTimestampsWithDefaults( + valueWithVariableDefault = + timestampFromUTCDateAndTime(3575, 4, 12, 10, 11, 12, 541991000), + valueWithSchemaDefault = timestampFromUTCDateAndTime(6224, 1, 31, 14, 2, 45, 714214000), + epoch = ZERO_TIMESTAMP, + requestTime1 = expectedRequestTime, + requestTime2 = expectedRequestTime, + ) + ) + ) + ) + } + + @Test + fun insertNullForNonNullTimestampFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullTimestamp.executeWithStringVariables(null).data.key + } + } + + @Test + fun insertIntForNonNullTimestampFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullTimestamp.executeWithIntVariables(777_666).data.key + } + } + + @Test + fun insertWithMissingValueNonNullTimestampFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNonNullTimestamp.executeWithEmptyVariables().data.key + } + } + + @Test + fun insertInvalidTimestampsValuesForNonNullTimestampFieldShouldFail() = + runTest(timeout = 60.seconds) { + for (invalidTimestamp in invalidTimestamps) { + assertThrows(DataConnectException::class) { + connector.insertNonNullTimestamp.executeWithStringVariables(invalidTimestamp) + } + } + } + + @Test + @Ignore( + "TODO(b/341984878): Add these test cases back to `invalidTimestamps` once the " + + "emulator is fixed to correctly reject them" + ) + fun insertInvalidTimestampsValuesForNonNullTimestampFieldShouldFailBugs() = runTest { + for (invalidTimestamp in invalidTimestampsThatAreErroneouslyAcceptedByTheServer) { + assertThrows(DataConnectException::class) { + connector.insertNonNullTimestamp.executeWithStringVariables(invalidTimestamp) + } + } + } + + @Test + fun updateNonNullTimestampFieldToAnotherValidValue() = runTest { + val timestamp1 = randomTimestamp() + val timestamp2 = timestampFromUTCDateAndTime(1795, 1, 12, 19, 3, 56, 40585847) + val key = connector.insertNonNullTimestamp.execute(timestamp1).data.key + connector.updateNonNullTimestamp.execute(key) { value = timestamp2 } + assertNonNullTimestampByKeyEquals(key, "1795-01-12T19:03:56.040585Z") + } + + @Test + fun updateNonNullTimestampFieldToMinValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + connector.updateNonNullTimestamp.execute(key) { value = MIN_TIMESTAMP } + assertNonNullTimestampByKeyEquals(key, "1583-01-01T00:00:00.000000Z") + } + + @Test + fun updateNonNullTimestampFieldToMaxValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + connector.updateNonNullTimestamp.execute(key) { value = MAX_TIMESTAMP } + assertNonNullTimestampByKeyEquals(key, "9999-12-31T23:59:59.999999Z") + } + + @Test + fun updateNonNullTimestampFieldToAnUndefinedValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNonNullTimestamp.execute(timestamp).data.key + connector.updateNonNullTimestamp.execute(key) {} + assertNonNullTimestampByKeyEquals(key, timestamp.withMicrosecondPrecision()) + } + + @Test + fun insertTypicalValueForNullableField() = runTest { + val timestamp = timestampFromUTCDateAndTime(1891, 5, 13, 5, 20, 38, 646067609) + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + assertNullableTimestampByKeyEquals(key, "1891-05-13T05:20:38.646067Z") + } + + @Test + fun insertMaxValueForNullableTimestampField() = runTest { + val key = connector.insertNullableTimestamp.execute { value = MIN_TIMESTAMP }.data.key + assertNullableTimestampByKeyEquals(key, "1583-01-01T00:00:00.000000Z") + } + + @Test + fun insertMinValueForNullableTimestampField() = runTest { + val key = connector.insertNullableTimestamp.execute { value = MAX_TIMESTAMP }.data.key + assertNullableTimestampByKeyEquals(key, "9999-12-31T23:59:59.999999Z") + } + + @Test + fun insertNullForNullableTimestampField() = runTest { + val key = connector.insertNullableTimestamp.execute { value = null }.data.key + assertNullableTimestampByKeyEquals(key, null) + } + + @Test + fun insertUndefinedForNullableTimestampField() = runTest { + val key = connector.insertNullableTimestamp.execute {}.data.key + assertNullableTimestampByKeyEquals(key, null) + } + + @Test + fun insertTimestampWithSingleDigitsForNullableTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(6651, 1, 2, 3, 4, 5, 6000) + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + assertNullableTimestampByKeyEquals(key, "6651-01-02T03:04:05.000006Z") + } + + @Test + fun insertTimestampWithAllDigitsForNullableTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(7992, 10, 11, 12, 13, 14, 123456789) + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + assertNullableTimestampByKeyEquals(key, "7992-10-11T12:13:14.123456Z") + } + + @Test + fun insertTimestampWithNoNanosecondsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.000000Z") + } + + @Test + fun insertTimestampWithZeroNanosecondsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.000000000Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.000000Z") + } + + @Test + fun insertTimestampWith1NanosecondsDigitForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.100000Z") + } + + @Test + fun insertTimestampWith2NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.120000Z") + } + + @Test + fun insertTimestampWith3NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123000Z") + } + + @Test + fun insertTimestampWith4NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1234Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123400Z") + } + + @Test + fun insertTimestampWith5NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12345Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123450Z") + } + + @Test + fun insertTimestampWith6NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith7NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.1234567Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith8NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.12345678Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWith9NanosecondsDigitsForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789Z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertIntForNullableTimestampFieldShouldFail() = runTest { + assertThrows(DataConnectException::class) { + connector.insertNullableTimestamp.executeWithIntVariables(555_444).data.key + } + } + + @Test + fun insertInvalidTimestampsValuesForNullableTimestampFieldShouldFail() = + runTest(timeout = 60.seconds) { + for (invalidTimestamp in invalidTimestamps) { + assertThrows(DataConnectException::class) { + connector.insertNullableTimestamp.executeWithStringVariables(invalidTimestamp) + } + } + } + + @Test + @Ignore( + "TODO(b/341984878): Add these test cases back to `invalidTimestamps` once the " + + "emulator is fixed to correctly reject them" + ) + fun insertInvalidTimestampsValuesForNullableTimestampFieldShouldFailBugs() = runTest { + for (invalidTimestamp in invalidTimestampsThatAreErroneouslyAcceptedByTheServer) { + assertThrows(DataConnectException::class) { + connector.insertNullableTimestamp.executeWithStringVariables(invalidTimestamp) + } + } + } + + @Test + @Ignore("TODO(b/341984878): Re-enable this test once the backend accepts leap seconds") + fun insertTimestampWithLeapSecondStringForNullableTimestampField() = runTest { + val timestamp = "1990-12-31T23:59:60Z" // From RFC3339 section 5.8 + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "1990-12-31T23:59:60.000000Z") + } + + @Test + fun insertTimestampWithLeapSecondDateForNullableTimestampField() = runTest { + val timestamp = timestampFromUTCDateAndTime(1990, 12, 31, 23, 59, 60, 0) + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + assertNullableTimestampByKeyEquals(key, timestamp) + } + + @Test + fun insertTimestampWithPlus0TimeZoneOffsetForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789+00:00" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertTimestampWithPositiveNonZeroTimeZoneOffsetForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789+01:23" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T11:22:56.123456Z") + } + + @Test + fun insertTimestampWithNegativeNonZeroTimeZoneOffsetForNullableTimestampField() = runTest { + val timestamp = "2024-05-18T12:45:56.123456789-01:23" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T14:08:56.123456Z") + } + + @Test + @Ignore("TODO(b/341984878): Re-enable this test once the backend accepts lowercase T and Z") + fun insertTimestampWithLowercaseTandZForNullableTimestampField() = runTest { + val timestamp = "2024-05-18t12:45:56.123456789z" + val key = connector.insertNullableTimestamp.executeWithStringVariables(timestamp).data.key + assertNullableTimestampByKeyEquals(key, "2024-05-18T12:45:56.123456Z") + } + + @Test + fun insertNoVariablesForNullableTimestampFieldsWithSchemaDefaults() = runTest { + val key = connector.insertNullableTimestampsWithDefaults.execute {}.data.key + val queryResult = connector.getNullableTimestampsWithDefaultsByKey.execute(key) + + // Since we can't know the exact value of `request.time` just make sure that the exact same + // value is used for both fields to which it is set. + val expectedRequestTime = queryResult.data.nullableTimestampsWithDefaults!!.requestTime1 + + assertThat( + queryResult.equals( + GetNullableTimestampsWithDefaultsByKeyQuery.Data( + GetNullableTimestampsWithDefaultsByKeyQuery.Data.NullableTimestampsWithDefaults( + valueWithVariableDefault = + timestampFromUTCDateAndTime(2554, 12, 20, 13, 3, 45, 110429000), + valueWithSchemaDefault = timestampFromUTCDateAndTime(1621, 12, 3, 1, 22, 3, 513914000), + epoch = ZERO_TIMESTAMP, + requestTime1 = expectedRequestTime, + requestTime2 = expectedRequestTime, + ) + ) + ) + ) + } + + @Test + fun updateNullableTimestampFieldToAnotherValidValue() = runTest { + val timestamp1 = randomTimestamp() + val timestamp2 = timestampFromUTCDateAndTime(7947, 7, 22, 13, 19, 55, 669650046) + val key = connector.insertNullableTimestamp.execute { value = timestamp1 }.data.key + connector.updateNullableTimestamp.execute(key) { value = timestamp2 } + assertNullableTimestampByKeyEquals(key, "7947-07-22T13:19:55.669650Z") + } + + @Test + fun updateNullableTimestampFieldToMinValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + connector.updateNullableTimestamp.execute(key) { value = MIN_TIMESTAMP } + assertNullableTimestampByKeyEquals(key, "1583-01-01T00:00:00.000000Z") + } + + @Test + fun updateNullableTimestampFieldToMaxValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + connector.updateNullableTimestamp.execute(key) { value = MAX_TIMESTAMP } + assertNullableTimestampByKeyEquals(key, "9999-12-31T23:59:59.999999Z") + } + + @Test + fun updateNullableTimestampFieldToNull() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + connector.updateNullableTimestamp.execute(key) { value = null } + assertNullableTimestampByKeyEquals(key, null) + } + + @Test + fun updateNullableTimestampFieldToNonNull() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNullableTimestamp.execute { value = null }.data.key + connector.updateNullableTimestamp.execute(key) { value = timestamp } + assertNullableTimestampByKeyEquals(key, timestamp.withMicrosecondPrecision()) + } + + @Test + fun updateNullableTimestampFieldToAnUndefinedValue() = runTest { + val timestamp = randomTimestamp() + val key = connector.insertNullableTimestamp.execute { value = timestamp }.data.key + connector.updateNullableTimestamp.execute(key) {} + assertNullableTimestampByKeyEquals(key, timestamp.withMicrosecondPrecision()) + } + + private suspend fun assertNonNullTimestampByKeyEquals( + key: NonNullTimestampKey, + expected: String + ) { + val queryResult = + connector.getNonNullTimestampByKey + .withDataDeserializer(serializer()) + .execute(key) + assertThat(queryResult.data).isEqualTo(GetTimestampByKeyQueryStringData(expected)) + } + + private suspend fun assertNonNullTimestampByKeyEquals( + key: NonNullTimestampKey, + expected: Timestamp + ) { + val queryResult = connector.getNonNullTimestampByKey.execute(key) + assertThat(queryResult.data) + .isEqualTo( + GetNonNullTimestampByKeyQuery.Data(GetNonNullTimestampByKeyQuery.Data.Value(expected)) + ) + } + + private suspend fun assertNullableTimestampByKeyEquals( + key: NullableTimestampKey, + expected: String + ) { + val queryResult = + connector.getNullableTimestampByKey + .withDataDeserializer(serializer()) + .execute(key) + assertThat(queryResult.data).isEqualTo(GetTimestampByKeyQueryStringData(expected)) + } + + private suspend fun assertNullableTimestampByKeyEquals( + key: NullableTimestampKey, + expected: Timestamp? + ) { + val queryResult = connector.getNullableTimestampByKey.execute(key) + assertThat(queryResult.data) + .isEqualTo( + GetNullableTimestampByKeyQuery.Data(GetNullableTimestampByKeyQuery.Data.Value(expected)) + ) + } + + /** + * A `Data` type that can be used in place of [GetNonNullTimestampByKeyQuery.Data] that types the + * value as a [String] instead of a [Timestamp], allowing verification of the data sent over the + * wire without possible confounding from timestamp deserialization. + */ + @Serializable + private data class GetTimestampByKeyQueryStringData(val value: TimestampStringValue?) { + constructor(value: String) : this(TimestampStringValue(value)) + @Serializable data class TimestampStringValue(val value: String) + } + + /** + * A `Variables` type that can be used in place of [InsertNonNullTimestampMutation.Variables] that + * types the value as a [String] instead of a [Timestamp], allowing verification of the data sent + * over the wire without possible confounding from timestamp serialization. + */ + @Serializable private data class InsertTimestampStringVariables(val value: String?) + + /** + * A `Variables` type that can be used in place of [InsertNonNullTimestampMutation.Variables] that + * types the value as a [Int] instead of a [Timestamp], allowing verification that the server + * fails with an expected error (rather than crashing, for example). + */ + @Serializable private data class InsertTimestampIntVariables(val value: Int) + + private companion object { + + suspend fun GeneratedMutation<*, Data, *>.executeWithStringVariables(value: String?) = + withVariablesSerializer(serializer()) + .ref(InsertTimestampStringVariables(value)) + .execute() + + suspend fun GeneratedMutation<*, Data, *>.executeWithIntVariables(value: Int) = + withVariablesSerializer(serializer()) + .ref(InsertTimestampIntVariables(value)) + .execute() + + suspend fun GeneratedQuery<*, Data, GetNonNullTimestampByKeyQuery.Variables>.execute( + key: NonNullTimestampKey + ) = ref(GetNonNullTimestampByKeyQuery.Variables(key)).execute() + + suspend fun GeneratedQuery<*, Data, GetNullableTimestampByKeyQuery.Variables>.execute( + key: NullableTimestampKey + ) = ref(GetNullableTimestampByKeyQuery.Variables(key)).execute() + + /** Convenience function to use when writing tests that will generate random timestamps. */ + @Suppress("unused") + fun printRandomTimestamps() { + repeat(100) { + val year = Random.nextInt(0..9999) + val month = Random.nextInt(0..11) + val day = Random.nextInt(0..28) + val hour = Random.nextInt(0..23) + val minute = Random.nextInt(0..59) + val second = Random.nextInt(0..59) + val nanoseconds = Random.nextInt(0..999_999_999) + println( + buildString { + append( + "timestampFromDateAndTimeUTC($year, $month, $day, $hour, $minute, $second, $nanoseconds)" + ) + append(" // ") + append("$year".padStart(4, '0')) + append('-') + append("$month".padStart(2, '0')) + append('-') + append("$day".padStart(2, '0')) + append('T') + append("$hour".padStart(2, '0')) + append(':') + append("$minute".padStart(2, '0')) + append(':') + append("$second".padStart(2, '0')) + append('.') + append("$nanoseconds".padStart(9, '0')) + append('Z') + } + ) + } + } + + val invalidTimestamps = + listOf( + "", + "foobar", + + // Partial timestamps + "2", + "20", + "202", + "2024", + "2024-", + "2024-0", + "2024-05", + "2024-05-", + "2024-05-1", + "2024-05-18", + "2024-05-18T", + "2024-05-18T1", + "2024-05-18T12", + "2024-05-18T12:", + "2024-05-18T12:4", + "2024-05-18T12:45", + "2024-05-18T12:45:", + "2024-05-18T12:45:5", + + // Missing components + "-05-18T12:45:56.123456000Z", + "2024--18T12:45:56.123456000Z", + "2024-05-T12:45:56.123456000Z", + "2024-05-18T:45:56.123456000Z", + "2024-05-18T12::56.123456000Z", + "2024-05-18T12:45:.123456000Z", + + // Invalid Year + "2-05-18T12:45:56.123456Z", + "20-05-18T12:45:56.123456Z", + "202-05-18T12:45:56.123456Z", + "20245-05-18T12:45:56.123456Z", + "02024-05-18T12:45:56.123456Z", + "ABCD-05-18T12:45:56.123456Z", + + // Invalid Month + "2024-0-18T12:45:56.123456000Z", + "2024-012-18T12:45:56.123456000Z", + "2024-123-18T12:45:56.123456000Z", + "2024-00-18T12:45:56.123456000Z", + "2024-13-18T12:45:56.123456000Z", + "2024-M-18T12:45:56.123456000Z", + "2024-MA-18T12:45:56.123456000Z", + + // Invalid Day + "2024-05-0T12:45:56.123456000Z", + "2024-05-1T12:45:56.123456000Z", + "2024-05-123T12:45:56.123456000Z", + "2024-05-00T12:45:56.123456000Z", + "2024-05-33T12:45:56.123456000Z", + "2024-05-MT12:45:56.123456000Z", + "2024-05-MAT12:45:56.123456000Z", + + // Invalid Hour + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T0:45:56.123456000Z", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T1:45:56.123456000Z", + "2024-05-18T012:45:56.123456000Z", + "2024-05-18T123:45:56.123456000Z", + "2024-05-18T24:45:56.123456000Z", + "2024-05-18TM:45:56.123456000Z", + "2024-05-18TMA:45:56.123456000Z", + "2024-05-18TMAT:45:56.123456000Z", + + // Invalid Minute + "2024-05-18T12:0:56.123456000Z", + "2024-05-18T12:1:56.123456000Z", + "2024-05-18T12:012:56.123456000Z", + "2024-05-18T12:123:56.123456000Z", + "2024-05-18T12:60:56.123456000Z", + "2024-05-18T12:M:56.123456000Z", + "2024-05-18T12:MA:56.123456000Z", + "2024-05-18T12:MAT:56.123456000Z", + + // Invalid Second + "2024-05-18T12:45:0.123456000Z", + "2024-05-18T12:45:1.123456000Z", + "2024-05-18T12:45:012.123456000Z", + "2024-05-18T12:45:123.123456000Z", + "2024-05-18T12:45:60.123456000Z", + "2024-05-18T12:45:M.123456000Z", + "2024-05-18T12:45:MA.123456000Z", + "2024-05-18T12:45:MAT.123456000Z", + + // Invalid Nanosecond + "2024-05-18T12:45:56.Z", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.1234567890Z", + "2024-05-18T12:45:56.MZ", + "2024-05-18T12:45:56.MASDMASDMAZ", + + // Invalid Time Zone + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000-00:00", + "2024-05-18T12:45:56.123456000ZZ", + "2024-05-18T12:45:56.123456000-0", + "2024-05-18T12:45:56.123456000-00", + "2024-05-18T12:45:56.123456000-:00", + "2024-05-18T12:45:56.123456000-3:00", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000-24:00", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000-99:00", + "2024-05-18T12:45:56.123456000-100:00", + "2024-05-18T12:45:56.123456000-010:00", + "2024-05-18T12:45:56.123456000-001:00", + "2024-05-18T12:45:56.123456000-M:00", + "2024-05-18T12:45:56.123456000-MA:00", + "2024-05-18T12:45:56.123456000-MAT:00", + "2024-05-18T12:45:56.123456000-02:", + "2024-05-18T12:45:56.123456000-02:0", + "2024-05-18T12:45:56.123456000-02:1", + "2024-05-18T12:45:56.123456000-02:010", + "2024-05-18T12:45:56.123456000-02:123", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000-02:60", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000-02:99", + "2024-05-18T12:45:56.123456000-02:M", + "2024-05-18T12:45:56.123456000-02:MA", + "2024-05-18T12:45:56.123456000-02:MAT", + "2024-05-18T12:45:56.123456000+0", + "2024-05-18T12:45:56.123456000+00", + "2024-05-18T12:45:56.123456000+:00", + "2024-05-18T12:45:56.123456000+3:00", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000+24:00", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000+99:00", + "2024-05-18T12:45:56.123456000+100:00", + "2024-05-18T12:45:56.123456000+010:00", + "2024-05-18T12:45:56.123456000+001:00", + "2024-05-18T12:45:56.123456000+M:00", + "2024-05-18T12:45:56.123456000+MA:00", + "2024-05-18T12:45:56.123456000+MAT:00", + "2024-05-18T12:45:56.123456000+02:", + "2024-05-18T12:45:56.123456000+02:0", + "2024-05-18T12:45:56.123456000+02:1", + "2024-05-18T12:45:56.123456000+02:010", + "2024-05-18T12:45:56.123456000+02:123", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000+02:60", + // TODO(b/341984878): Uncomment once fixed: "2024-05-18T12:45:56.123456000+02:99", + "2024-05-18T12:45:56.123456000+02:M", + "2024-05-18T12:45:56.123456000+02:MA", + "2024-05-18T12:45:56.123456000+02:MAT", + + // Bogus Characters + "a2024-05-18T12:45:56.123456789Z", + "2024-05-18T12:45:56.123456789Za", + "2024:05-18T12:45:56.123456789Z", + "2024-05:18T12:45:56.123456789Z", + "2024-05-18 12:45:56.123456789Z", + "2024-05-18T12-45:56.123456789Z", + "2024-05-18T12:45-56.123456789Z", + "2024-05-18T12:45:56-123456789Z", + + // Out-of-range Values + "0000-01-01T12:45:56Z", + "2024-00-22T12:45:56Z", + "2024-13-22T12:45:56Z", + "2024-11-00T12:45:56Z", + "2024-01-32T12:45:56Z", + "2025-02-29T12:45:56Z", + "2024-02-30T12:45:56Z", + "2024-03-32T12:45:56Z", + "2024-04-31T12:45:56Z", + "2024-05-32T12:45:56Z", + "2024-06-31T12:45:56Z", + "2024-07-32T12:45:56Z", + "2024-08-32T12:45:56Z", + "2024-09-31T12:45:56Z", + "2024-10-32T12:45:56Z", + "2024-11-31T12:45:56Z", + "2024-12-32T12:45:56Z", + + // Test cases from https://scalars.graphql.org/andimarek/date-time (some omitted since they + // are indeed valid for Firebase Data Connect) + "2011-08-30T13:22:53.108-03", // The minutes of the offset are missing. + "2011-08-30T13:22:53.108", // No offset provided. + "2011-08-30", // No time provided. + "2011-08-30T13:22:53.108+03:30:15", // Seconds are not allowed for the offset + "2011-08-30T24:22:53.108Z", // 24 is not allowed as hour of the time. + "2010-02-30T21:22:53.108Z", // 30th of February is not a valid date + "2010-02-11T21:22:53.108Z+25:11", // 25 is not a valid hour for offset + ) + + // TODO(b/341984878): Remove elements from this list as they are fixed, and uncomment them + // in the list above. + val invalidTimestampsThatAreErroneouslyAcceptedByTheServer = + listOf( + "2024-05-18T0:45:56.123456000Z", + "2024-05-18T1:45:56.123456000Z", + "2024-05-18T12:45:56.1234567890Z", + "2024-05-18T12:45:56.123456000-00:00", + "2024-05-18T12:45:56.123456000-24:00", + "2024-05-18T12:45:56.123456000-99:00", + "2024-05-18T12:45:56.123456000+24:00", + "2024-05-18T12:45:56.123456000+99:00", + "2024-05-18T12:45:56.123456000-02:60", + "2024-05-18T12:45:56.123456000-02:99", + "2024-05-18T12:45:56.123456000+02:60", + "2024-05-18T12:45:56.123456000+02:99", + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorIntegrationTestBase.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorIntegrationTestBase.kt new file mode 100644 index 00000000000..4a4dead2eab --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorIntegrationTestBase.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo.testutil + +import com.google.firebase.dataconnect.connectors.demo.DemoConnector +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import org.junit.Rule + +abstract class DemoConnectorIntegrationTestBase : DataConnectIntegrationTestBase() { + + @get:Rule + val demoConnectorFactory = TestDemoConnectorFactory(firebaseAppFactory, dataConnectFactory) + + val connector: DemoConnector by lazy { demoConnectorFactory.newInstance() } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorTruth.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorTruth.kt new file mode 100644 index 00000000000..8089e80a70b --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorTruth.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo.testutil + +import androidx.annotation.CheckResult +import com.google.firebase.dataconnect.connectors.demo.DemoConnector +import com.google.firebase.dataconnect.connectors.demo.GetFooByIdQuery +import com.google.firebase.dataconnect.connectors.demo.execute +import com.google.firebase.dataconnect.testutil.fail + +/** + * Returns an object with which fluent assertions can be made using the given [DemoConnector] + * instance, similar to [com.google.common.truth.Truth] assertions. + */ +fun assertWith(connector: DemoConnector): DemoConnectorSubject = DemoConnectorSubjectImpl(connector) + +interface DemoConnectorSubject { + + /** Returns an object with which assertions can be performed about a `Foo` with the given ID. */ + @CheckResult fun thatFooWithId(id: String): FooSubject + + /** + * Returns an object with which assertions can be performed about all `Foo` objects whose `bar` + * field is equal to the given value. + */ + @CheckResult fun thatFoosWithBar(bar: String): FooListSubject + + /** Provides methods for performing assertions on a `Foo` object. */ + interface FooSubject { + + /** Throws if the `Foo` does not exist. */ + suspend fun exists() + + /** Throws if the `Foo` exists. */ + suspend fun doesNotExist() + + /** + * Throws if the `Foo` does not exist, or exists with a `bar` field value different than the + * given value. + */ + suspend fun existsWithBar(expectedBar: String) + } + + /** Provides methods for performing assertions on a (possibly empty) list of `Foo` objects. */ + interface FooListSubject { + + /** Throws if the number of existing `Foo` objects is different than the given value. */ + suspend fun exist(expectedCount: Int) + + /** Throws if one or more `Foo` objects exist. */ + suspend fun doNotExist() + } +} + +private class DemoConnectorSubjectImpl(private val connector: DemoConnector) : + DemoConnectorSubject { + override fun thatFooWithId(id: String) = FooSubjectImpl(connector, id) + override fun thatFoosWithBar(bar: String) = FooListSubjectImpl(connector, bar) +} + +private class FooSubjectImpl(private val connector: DemoConnector, private val id: String) : + DemoConnectorSubject.FooSubject { + override suspend fun exists() { + loadFoo() ?: fail("Expected Foo with id=$id to exist, but it does not exist") + } + + override suspend fun existsWithBar(expectedBar: String) { + val foo = loadFoo() + if (foo == null) { + fail("Expected Foo with id=$id to exist with bar=$expectedBar, but it does not exist at all") + } else if (foo.bar != expectedBar) { + fail( + "Expected Foo with id=$id to exist with bar=$expectedBar, and it does exist, " + + "but its bar is different: ${foo.bar}" + ) + } + } + + override suspend fun doesNotExist() { + val foo = loadFoo() + if (foo != null) { + fail("Expected Foo with id=$id to not exist, but it exists with bar=${foo.bar}") + } + } + + private suspend fun loadFoo(): GetFooByIdQuery.Data.Foo? = + connector.getFooById.execute(id).data.foo +} + +private class FooListSubjectImpl(private val connector: DemoConnector, private val bar: String) : + DemoConnectorSubject.FooListSubject { + + override suspend fun doNotExist() { + val count = fooCount() + if (count > 0) { + fail("Expected zero Foo rows to exist with bar=$bar to exist, but found $count") + } + } + + override suspend fun exist(expectedCount: Int) { + val count = fooCount() + if (count != expectedCount) { + fail("Expected ${expectedCount} Foo rows to exist with bar=$bar to exist, but found $count") + } + } + + private suspend fun fooCount(): Int = + connector.getFoosByBar.execute { bar = this@FooListSubjectImpl.bar }.data.foos.size +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/TestDemoConnectorFactory.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/TestDemoConnectorFactory.kt new file mode 100644 index 00000000000..267c24702a7 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/TestDemoConnectorFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo.testutil + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.connectors.demo.DemoConnector +import com.google.firebase.dataconnect.connectors.demo.getInstance +import com.google.firebase.dataconnect.connectors.testutil.TestConnectorFactory +import com.google.firebase.dataconnect.testutil.TestDataConnectFactory +import com.google.firebase.dataconnect.testutil.TestFirebaseAppFactory + +/** + * A JUnit test rule that creates instances of [DemoConnector] for use during testing, and closes + * their underlying [FirebaseDataConnect] instances upon test completion. + */ +class TestDemoConnectorFactory( + firebaseAppFactory: TestFirebaseAppFactory, + dataConnectFactory: TestDataConnectFactory +) : TestConnectorFactory(firebaseAppFactory, dataConnectFactory) { + override fun createConnector(firebaseApp: FirebaseApp, settings: DataConnectSettings) = + DemoConnector.getInstance(firebaseApp, settings) +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/KeywordsConnectorIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/KeywordsConnectorIntegrationTest.kt new file mode 100644 index 00000000000..d2f1b8fc779 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/KeywordsConnectorIntegrationTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.keywords + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.connectors.demo.* +import com.google.firebase.dataconnect.connectors.demo.testutil.* +import com.google.firebase.dataconnect.connectors.`typealias`.* +import com.google.firebase.dataconnect.connectors.`typealias`.DeleteFooMutation +import com.google.firebase.dataconnect.connectors.`typealias`.FooKey +import com.google.firebase.dataconnect.connectors.`typealias`.GetFoosByBarQuery +import com.google.firebase.dataconnect.testutil.* +import kotlinx.coroutines.test.* +import org.junit.Rule +import org.junit.Test + +class KeywordsConnectorIntegrationTest : DataConnectIntegrationTestBase() { + + @get:Rule + val keywordsConnectorFactory = + TestKeywordsConnectorFactory(firebaseAppFactory, dataConnectFactory) + + @get:Rule + val demoConnectorFactory = TestDemoConnectorFactory(firebaseAppFactory, dataConnectFactory) + + val keywordsConnector: KeywordsConnector by lazy { keywordsConnectorFactory.newInstance() } + val demoConnector: DemoConnector by lazy { demoConnectorFactory.newInstance() } + + @Test + fun mutationNameShouldBeEscapedIfItIsAKotlinKeyword() = runTest { + val id = "id_" + randomAlphanumericString() + val bar = "bar_" + randomAlphanumericString() + + // The "do" mutation inserts a Foo into the database. + val mutationResult = keywordsConnector.`do`.execute(id = id) { this.bar = bar } + + assertThat(mutationResult.data).isEqualTo(DoMutation.Data(FooKey(id))) + val queryResult = demoConnector.getFooById.execute(id) + assertThat(queryResult.data).isEqualTo(GetFooByIdQuery.Data(GetFooByIdQuery.Data.Foo(bar))) + } + + @Test + fun queryNameShouldBeEscapedIfItIsAKotlinKeyword() = runTest { + val id = "id_" + randomAlphanumericString() + val bar = "bar_" + randomAlphanumericString() + demoConnector.insertFoo.execute(id = id) { this.bar = bar } + + // The "return" query gets a Foo from the database by its ID. + val queryResult = keywordsConnector.`return`.execute(id) + + assertThat(queryResult.data).isEqualTo(ReturnQuery.Data(ReturnQuery.Data.Foo(bar))) + } + + @Test + fun mutationVariableNamesShouldBeEscapedIfTheyAreKotlinKeywords() = runTest { + val id = "id_" + randomAlphanumericString() + val bar = "bar_" + randomAlphanumericString() + demoConnector.insertFoo.execute(id = id) { this.bar = bar } + + // The "is" variable is the ID of the row to delete. + val mutationResult = keywordsConnector.deleteFoo.execute(`is` = id) + + assertThat(mutationResult.data).isEqualTo(DeleteFooMutation.Data(FooKey(id))) + val queryResult = demoConnector.getFooById.execute(id) + assertThat(queryResult.data.foo).isNull() + } + + @Test + fun queryVariableNamesShouldBeEscapedIfTheyAreKotlinKeywords() = runTest { + val id1 = "id1_" + randomAlphanumericString() + val id2 = "id2_" + randomAlphanumericString() + val id3 = "id3_" + randomAlphanumericString() + val bar = "bar_" + randomAlphanumericString() + demoConnector.insertFoo.execute(id = id1) { this.bar = bar } + demoConnector.insertFoo.execute(id = id2) { this.bar = bar } + demoConnector.insertFoo.execute(id = id3) { this.bar = bar } + + // The "as" variable is the value of "bar" whose rows to return. + val queryResult = keywordsConnector.getFoosByBar.execute { `as` = bar } + + assertThat(queryResult.data.foos) + .containsExactly( + GetFoosByBarQuery.Data.FoosItem(id1), + GetFoosByBarQuery.Data.FoosItem(id2), + GetFoosByBarQuery.Data.FoosItem(id3), + ) + } + + @Test + fun mutationSelectionSetFieldNamesShouldBeEscapedIfTheyAreKotlinKeywords() = runTest { + val id1 = "id1_" + randomAlphanumericString() + val id2 = "id2_" + randomAlphanumericString() + val bar1 = "bar1_" + randomAlphanumericString() + val bar2 = "bar2_" + randomAlphanumericString() + + val mutationResult = + keywordsConnector.insertTwoFoos.execute(id1 = id1, id2 = id2) { + this.bar1 = bar1 + this.bar2 = bar2 + } + + // The `val` and `var` fields are the keys of the 1st and 2nd inserted rows, respectively. + assertThat(mutationResult.data) + .isEqualTo( + InsertTwoFoosMutation.Data( + `val` = FooKey(id1), + `var` = FooKey(id2), + ) + ) + val queryResult1 = demoConnector.getFooById.execute(id1) + assertThat(queryResult1.data).isEqualTo(GetFooByIdQuery.Data(GetFooByIdQuery.Data.Foo(bar1))) + val queryResult2 = demoConnector.getFooById.execute(id2) + assertThat(queryResult2.data).isEqualTo(GetFooByIdQuery.Data(GetFooByIdQuery.Data.Foo(bar2))) + } + + @Test + fun querySelectionSetFieldNamesShouldBeEscapedIfTheyAreKotlinKeywords() = runTest { + val id1 = "id1_" + randomAlphanumericString() + val id2 = "id2_" + randomAlphanumericString() + val bar1 = "bar1_" + randomAlphanumericString() + val bar2 = "bar2_" + randomAlphanumericString() + demoConnector.insertFoo.execute(id = id1) { bar = bar1 } + demoConnector.insertFoo.execute(id = id2) { bar = bar2 } + + val queryResult = keywordsConnector.getTwoFoosById.execute(id1 = id1, id2 = id2) + + // The `super` and `this` fields are the rows with the 1st and 2nd IDs, respectively. + assertThat(queryResult.data) + .isEqualTo( + GetTwoFoosByIdQuery.Data( + `super` = GetTwoFoosByIdQuery.Data.Super(id = id1, bar = bar1), + `this` = GetTwoFoosByIdQuery.Data.This(id = id2, bar = bar2), + ) + ) + } +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/TestKeywordsConnectorFactory.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/TestKeywordsConnectorFactory.kt new file mode 100644 index 00000000000..a229b5e7561 --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/TestKeywordsConnectorFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.keywords + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.connectors.testutil.TestConnectorFactory +import com.google.firebase.dataconnect.connectors.`typealias`.KeywordsConnector +import com.google.firebase.dataconnect.connectors.`typealias`.getInstance +import com.google.firebase.dataconnect.testutil.TestDataConnectFactory +import com.google.firebase.dataconnect.testutil.TestFirebaseAppFactory + +/** + * A JUnit test rule that creates instances of [KeywordsConnector] for use during testing, and + * closes their underlying [FirebaseDataConnect] instances upon test completion. + */ +class TestKeywordsConnectorFactory( + firebaseAppFactory: TestFirebaseAppFactory, + dataConnectFactory: TestDataConnectFactory +) : TestConnectorFactory(firebaseAppFactory, dataConnectFactory) { + override fun createConnector(firebaseApp: FirebaseApp, settings: DataConnectSettings) = + KeywordsConnector.getInstance(firebaseApp, settings) +} diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/testutil/TestConnectorFactory.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/testutil/TestConnectorFactory.kt new file mode 100644 index 00000000000..873aa8c19dd --- /dev/null +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/testutil/TestConnectorFactory.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.testutil + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.generated.* +import com.google.firebase.dataconnect.testutil.DataConnectBackend +import com.google.firebase.dataconnect.testutil.FactoryTestRule +import com.google.firebase.dataconnect.testutil.TestDataConnectFactory +import com.google.firebase.dataconnect.testutil.TestFirebaseAppFactory + +/** + * A JUnit test rule that creates instances of a connector for use during testing, and closes their + * underlying [FirebaseDataConnect] instances upon test completion. + */ +abstract class TestConnectorFactory( + private val firebaseAppFactory: TestFirebaseAppFactory, + private val dataConnectFactory: TestDataConnectFactory +) : FactoryTestRule() { + + abstract fun createConnector(firebaseApp: FirebaseApp, settings: DataConnectSettings): T + + override fun createInstance(params: Nothing?): T { + val firebaseApp = firebaseAppFactory.newInstance() + + val dataConnectSettings = DataConnectBackend.fromInstrumentationArguments().dataConnectSettings + val connector = createConnector(firebaseApp, dataConnectSettings) + + // Get the instance of `FirebaseDataConnect` from the `TestDataConnectFactory` so that it will + // register the instance and set any settings required for talking to the backend. + val dataConnect = dataConnectFactory.newInstance(firebaseApp, connector.dataConnect.config) + + check(dataConnect === connector.dataConnect) { + "DemoConnector.getInstance() returned an instance " + + "associated with FirebaseDataConnect instance ${connector.dataConnect}, " + + "but expected it to be associated with instance $dataConnect" + } + + return connector + } + + override fun destroyInstance(instance: T) { + // Do nothing in `destroyInstance()` since `TestDataConnectFactory` will do all the work of + // closing the `FirebaseDataConnect` instance. + } +} diff --git a/firebase-dataconnect/connectors/src/main/AndroidManifest.xml b/firebase-dataconnect/connectors/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..3272df4cc1f --- /dev/null +++ b/firebase-dataconnect/connectors/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreateComment.kt b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreateComment.kt new file mode 100644 index 00000000000..defbc1c5da5 --- /dev/null +++ b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreateComment.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import com.google.firebase.dataconnect.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer + +public class CreateComment internal constructor(public val connector: PostsConnector) { + + public fun ref(variables: Variables): MutationRef = + connector.dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) + + public fun ref(id: String, content: String, postId: String): MutationRef = + ref(Variables(id = id, content = content, postId = postId)) + + @Serializable + public data class Variables(val id: String, val content: String, val postId: String) + + public companion object { + public const val operationName: String = "createComment" + public val dataDeserializer: DeserializationStrategy = serializer() + public val variablesSerializer: SerializationStrategy = serializer() + } +} + +public suspend fun PostsConnector.createComment( + id: String, + content: String, + postId: String +): MutationResult = + createComment.ref(id = id, content = content, postId = postId).execute() diff --git a/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreatePost.kt b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreatePost.kt new file mode 100644 index 00000000000..bddacded137 --- /dev/null +++ b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreatePost.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import com.google.firebase.dataconnect.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer + +public class CreatePost internal constructor(public val connector: PostsConnector) { + + public fun ref(variables: Variables): MutationRef = + connector.dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) + + public fun ref(id: String, content: String): MutationRef = + ref(Variables(id = id, content = content)) + + @Serializable public data class Variables(val id: String, val content: String) + + public companion object { + public const val operationName: String = "createPost" + public val dataDeserializer: DeserializationStrategy = serializer() + public val variablesSerializer: SerializationStrategy = serializer() + } +} + +public suspend fun PostsConnector.createPost( + id: String, + content: String +): MutationResult = createPost.ref(id = id, content = content).execute() diff --git a/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/GetPost.kt b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/GetPost.kt new file mode 100644 index 00000000000..9e2b6bb5a4f --- /dev/null +++ b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/GetPost.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import com.google.firebase.dataconnect.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer + +public class GetPost internal constructor(public val connector: PostsConnector) { + + public fun ref(variables: Variables): QueryRef = + connector.dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) + + public fun ref(id: String): QueryRef = ref(Variables(id = id)) + + @Serializable + public data class Data(val post: Post?) { + @Serializable + public data class Post(val content: String, val comments: List) { + @Serializable public data class Comment(val id: String?, val content: String) + } + } + + @Serializable public data class Variables(val id: String) + + public data class FlowResult( + val result: QueryResult, + val exception: DataConnectException? + ) + + public companion object { + public const val operationName: String = "getPost" + public val dataDeserializer: DeserializationStrategy = serializer() + public val variablesSerializer: SerializationStrategy = serializer() + } +} + +public suspend fun PostsConnector.getPost( + id: String +): QueryResult = getPost.ref(id = id).execute() + +public fun GetPost.flow(id: String): Flow = TODO() diff --git a/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/PostsConnector.kt b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/PostsConnector.kt new file mode 100644 index 00000000000..e26d463dafa --- /dev/null +++ b/firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/PostsConnector.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.connectors.demo.DemoConnector +import java.util.WeakHashMap + +public class PostsConnector private constructor(public val dataConnect: FirebaseDataConnect) { + + public val getPost: GetPost by lazy { GetPost(this) } + public val createPost: CreatePost by lazy { CreatePost(this) } + public val createComment: CreateComment by lazy { CreateComment(this) } + + public companion object { + public val config: ConnectorConfig = DemoConnector.config.copy(connector = "posts") + + public val instance: PostsConnector + get() = getInstance(FirebaseDataConnect.getInstance(config)) + + public fun getInstance(app: FirebaseApp): PostsConnector = + getInstance(FirebaseDataConnect.getInstance(app, config)) + + public fun getInstance(settings: DataConnectSettings): PostsConnector = + getInstance(FirebaseDataConnect.getInstance(config, settings)) + + public fun getInstance(app: FirebaseApp, settings: DataConnectSettings): PostsConnector = + getInstance(FirebaseDataConnect.getInstance(app, config, settings)) + + private fun getInstance(dataConnect: FirebaseDataConnect): PostsConnector = + synchronized(instances) { instances.getOrPut(dataConnect) { PostsConnector(dataConnect) } } + + private val instances = WeakHashMap() + } +} diff --git a/firebase-dataconnect/connectors/src/test/AndroidManifest.xml b/firebase-dataconnect/connectors/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..4d68e2e4cf0 --- /dev/null +++ b/firebase-dataconnect/connectors/src/test/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorUnitTest.kt b/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorUnitTest.kt new file mode 100644 index 00000000000..b73a5b34a27 --- /dev/null +++ b/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorUnitTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.FirebaseAppUnitTestingRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PostsConnectorUnitTest { + + @get:Rule + val firebaseAppFactory = + FirebaseAppUnitTestingRule( + appNameKey = "ex2bk4bks2", + applicationIdKey = "2f2c3gdydn", + projectIdKey = "kzbqx23hhn" + ) + + private val posts by lazy { PostsConnector.getInstance(firebaseAppFactory.newInstance()) } + + @Test + fun `getPost property should always return the same instance`() { + val operation1 = posts.getPost + val operation2 = posts.getPost + + assertThat(operation1).isSameInstanceAs(operation2) + } + + @Test + fun `createPost property should always return the same instance`() { + val operation1 = posts.createPost + val operation2 = posts.createPost + + assertThat(operation1).isSameInstanceAs(operation2) + } + + @Test + fun `createComment property should always return the same instance`() { + val operation1 = posts.createComment + val operation2 = posts.createComment + + assertThat(operation1).isSameInstanceAs(operation2) + } +} diff --git a/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorCompanionUnitTest.kt b/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorCompanionUnitTest.kt new file mode 100644 index 00000000000..366b487e349 --- /dev/null +++ b/firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorCompanionUnitTest.kt @@ -0,0 +1,370 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.connectors.demo + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.getInstance +import com.google.firebase.dataconnect.testutil.FirebaseAppUnitTestingRule +import com.google.firebase.dataconnect.testutil.fail +import com.google.firebase.dataconnect.testutil.randomDataConnectSettings +import io.mockk.mockk +import java.util.concurrent.Executors +import java.util.concurrent.Future +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DemoConnectorCompanionUnitTest { + + @get:Rule + val firebaseAppFactory = + FirebaseAppUnitTestingRule( + appNameKey = "ex2bk4bks2", + applicationIdKey = "2f2c3gdydn", + projectIdKey = "kzbqx23hhn" + ) + + @Test + fun instance_ShouldBeAssociatedWithTheDataConnectInstanceAssociatedWithTheDefaultApp() { + val connector = DemoConnector.instance + + val defaultDataConnect = FirebaseDataConnect.getInstance(DemoConnector.config) + assertThat(connector.dataConnect).isSameInstanceAs(defaultDataConnect) + } + + @Test + fun instance_ShouldAlwaysReturnTheSameInstance() { + val connector1 = DemoConnector.instance + val connector2 = DemoConnector.instance + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun instance_ShouldUseTheDefaultSettings() { + val connector = DemoConnector.instance + + assertThat(connector.dataConnect.settings).isEqualTo(DataConnectSettings()) + } + + @Test + fun instance_ShouldReturnANewInstanceAfterTheUnderlyingDataConnectInstanceIsClosed() { + val connector1 = DemoConnector.instance + connector1.dataConnect.close() + val connector2 = DemoConnector.instance + + assertThat(connector1).isNotSameInstanceAs(connector2) + } + + @Test + fun instance_ShouldReturnANewInstanceWithTheNewDataConnectAfterTheUnderlyingDataConnectInstanceIsClosed() { + val connector1 = DemoConnector.instance + connector1.dataConnect.close() + val connector2 = DemoConnector.instance + + assertThat(connector1.dataConnect).isNotSameInstanceAs(connector2.dataConnect) + } + + @Test + fun instance_CanBeAccessedConcurrently() { + getInstanceConcurrentTest { DemoConnector.instance } + } + + @Test + fun getInstance_NoArgs_ShouldReturnSameObjectAsInstanceProperty() { + val connector = DemoConnector.getInstance() + + assertThat(connector).isSameInstanceAs(DemoConnector.instance) + } + + @Test + fun getInstance_NoArgs_ShouldAlwaysReturnTheSameInstance() { + val connector1 = DemoConnector.getInstance() + val connector2 = DemoConnector.getInstance() + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun getInstance_NoArgs_ShouldReturnSameObjectAsInstancePropertyAfterTheUnderlyingDataConnectInstanceIsClosed() { + val connector1 = DemoConnector.getInstance() + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance() + + assertThat(connector2).isSameInstanceAs(DemoConnector.instance) + } + + @Test + fun getInstance_NoArgs_CanBeCalledConcurrently() { + getInstanceConcurrentTest { DemoConnector.getInstance() } + } + + @Test + fun getInstance_Settings_ShouldBeAssociatedWithTheDataConnectInstanceAssociatedWithTheDefaultApp() { + val settings = randomDataConnectSettings("ma6w24rxs4") + val connector = DemoConnector.getInstance(settings) + + val defaultDataConnect = FirebaseDataConnect.getInstance(DemoConnector.config, settings) + assertThat(connector.dataConnect).isSameInstanceAs(defaultDataConnect) + } + + @Test + fun getInstance_Settings_ShouldAlwaysReturnTheSameInstance() { + val settings = randomDataConnectSettings("bpn9zdtrz6") + val connector1 = DemoConnector.getInstance(settings) + val connector2 = DemoConnector.getInstance(settings) + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun getInstance_Settings_ShouldUseTheSpecifiedSettings() { + val settings = randomDataConnectSettings("gcdzkbxezs") + val connector = DemoConnector.getInstance(settings) + + assertThat(connector.dataConnect.settings).isSameInstanceAs(settings) + } + + @Test + fun getInstance_Settings_ShouldReturnANewInstanceAfterTheUnderlyingDataConnectInstanceIsClosed() { + val settings1 = randomDataConnectSettings("th7rvb7pwz") + val settings2 = randomDataConnectSettings("cdhhcnejyz") + val connector1 = DemoConnector.getInstance(settings1) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(settings2) + + assertThat(connector1).isNotSameInstanceAs(connector2) + } + + @Test + fun getInstance_Settings_ShouldReturnANewInstanceWithTheNewDataConnectAfterTheUnderlyingDataConnectInstanceIsClosed() { + val settings1 = randomDataConnectSettings("marmvzw4hy") + val settings2 = randomDataConnectSettings("da683rksvr") + val connector1 = DemoConnector.getInstance(settings1) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(settings2) + + assertThat(connector1.dataConnect).isNotSameInstanceAs(connector2.dataConnect) + assertThat(connector1.dataConnect.settings).isEqualTo(settings1) + assertThat(connector2.dataConnect.settings).isEqualTo(settings2) + } + + @Test + fun getInstance_Settings_CanBeCalledConcurrently() { + val settings = randomDataConnectSettings("4s7g3xcbrc") + getInstanceConcurrentTest { DemoConnector.getInstance(settings) } + } + + @Test + fun getInstance_FirebaseApp_ShouldBeAssociatedWithTheDataConnectInstanceAssociatedWithTheSpecifiedApp() { + val firebaseApp = firebaseAppFactory.newInstance() + val connector = DemoConnector.getInstance(firebaseApp) + + val expectedDataConnect = FirebaseDataConnect.getInstance(firebaseApp, DemoConnector.config) + assertThat(connector.dataConnect).isSameInstanceAs(expectedDataConnect) + } + + @Test + fun getInstance_FirebaseApp_ShouldAlwaysReturnTheSameInstance() { + val firebaseApp = firebaseAppFactory.newInstance() + val connector1 = DemoConnector.getInstance(firebaseApp) + val connector2 = DemoConnector.getInstance(firebaseApp) + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseApp_ShouldUseTheDefaultSettings() { + val firebaseApp = firebaseAppFactory.newInstance() + val connector = DemoConnector.getInstance(firebaseApp) + + assertThat(connector.dataConnect.settings).isEqualTo(DataConnectSettings()) + } + + @Test + fun getInstance_FirebaseApp_ShouldReturnANewInstanceAfterTheUnderlyingDataConnectInstanceIsClosed() { + val firebaseApp = firebaseAppFactory.newInstance() + val connector1 = DemoConnector.getInstance(firebaseApp) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(firebaseApp) + + assertThat(connector1).isNotSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseApp_ShouldReturnANewInstanceWithTheNewDataConnectAfterTheUnderlyingDataConnectInstanceIsClosed() { + val firebaseApp = firebaseAppFactory.newInstance() + val connector1 = DemoConnector.getInstance(firebaseApp) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(firebaseApp) + + assertThat(connector1.dataConnect).isNotSameInstanceAs(connector2.dataConnect) + } + + @Test + fun getInstance_FirebaseApp_CanBeAccessedConcurrently() { + val firebaseApp = firebaseAppFactory.newInstance() + getInstanceConcurrentTest { DemoConnector.getInstance(firebaseApp) } + } + + @Test + fun getInstance_FirebaseApp_Settings_ShouldBeAssociatedWithTheDataConnectInstanceAssociatedWithTheSpecifiedApp() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("jskhwf9eex") + val connector = DemoConnector.getInstance(firebaseApp, settings) + + val expectedDataConnect = + FirebaseDataConnect.getInstance(firebaseApp, DemoConnector.config, settings) + assertThat(connector.dataConnect).isSameInstanceAs(expectedDataConnect) + } + + @Test + fun getInstance_FirebaseApp_Settings_ShouldAlwaysReturnTheSameInstance() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("6teq95kn7p") + val connector1 = DemoConnector.getInstance(firebaseApp, settings) + val connector2 = DemoConnector.getInstance(firebaseApp, settings) + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseApp_Settings_ShouldUseTheSpecifiedSettings() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("t5rz7675kf") + val connector = DemoConnector.getInstance(firebaseApp, settings) + + assertThat(connector.dataConnect.settings).isEqualTo(settings) + } + + @Test + fun getInstance_FirebaseApp_Settings_ShouldReturnANewInstanceAfterTheUnderlyingDataConnectInstanceIsClosed() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("gz5xbdkpje") + val connector1 = DemoConnector.getInstance(firebaseApp, settings) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(firebaseApp, settings) + + assertThat(connector1).isNotSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseApp_Settings_ShouldReturnANewInstanceWithTheNewDataConnectAfterTheUnderlyingDataConnectInstanceIsClosed() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("svydpf2csv") + val connector1 = DemoConnector.getInstance(firebaseApp, settings) + connector1.dataConnect.close() + val connector2 = DemoConnector.getInstance(firebaseApp, settings) + + assertThat(connector1.dataConnect).isNotSameInstanceAs(connector2.dataConnect) + } + + @Test + fun getInstance_FirebaseDataConnect_ShouldBeAssociatedWithTheDataConnectInstanceAssociatedWithTheSpecifiedApp() { + val dataConnect = mockk() + val connector = DemoConnector.getInstance(dataConnect) + + assertThat(connector.dataConnect).isSameInstanceAs(dataConnect) + } + + @Test + fun getInstance_FirebaseDataConnect_ShouldAlwaysReturnTheSameInstance() { + val dataConnect = mockk() + val connector1 = DemoConnector.getInstance(dataConnect) + val connector2 = DemoConnector.getInstance(dataConnect) + + assertThat(connector1).isSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseDataConnect_ShouldReturnADistinctConnectorForADistinctDataConnect() { + val dataConnect1 = mockk() + val dataConnect2 = mockk() + val connector1 = DemoConnector.getInstance(dataConnect1) + val connector2 = DemoConnector.getInstance(dataConnect2) + + assertThat(connector1).isNotSameInstanceAs(connector2) + } + + @Test + fun getInstance_FirebaseDataConnect_ShouldReturnADistinctConnectorWithTheDistinctDataConnect() { + val dataConnect1 = mockk() + val dataConnect2 = mockk() + val connector1 = DemoConnector.getInstance(dataConnect1) + val connector2 = DemoConnector.getInstance(dataConnect2) + + assertThat(connector1.dataConnect).isSameInstanceAs(dataConnect1) + assertThat(connector2.dataConnect).isSameInstanceAs(dataConnect2) + } + + @Test + fun getInstance_FirebaseDataConnect_CanBeAccessedConcurrently() { + val dataConnect = FirebaseDataConnect.getInstance(DemoConnector.config) + getInstanceConcurrentTest { DemoConnector.getInstance(dataConnect) } + } + + @Test + fun getInstance_FirebaseApp_Settings_CanBeAccessedConcurrently() { + val firebaseApp = firebaseAppFactory.newInstance() + val settings = randomDataConnectSettings("rwvr8jp4cp") + getInstanceConcurrentTest { DemoConnector.getInstance(firebaseApp, settings) } + } + + private fun getInstanceConcurrentTest(block: () -> DemoConnector) { + val connectors = mutableListOf() + val futures = mutableListOf>() + val executor = Executors.newFixedThreadPool(6) + try { + repeat(1000) { + executor + .submit { + val connector = block() + val size = + synchronized(connectors) { + connectors.add(connector) + connectors.size + } + if (size == 50) { + connector.dataConnect.close() + } + } + .also { futures.add(it) } + } + + futures.forEach { it.get() } + } finally { + executor.shutdownNow() + } + + assertWithMessage("connectors.size").that(connectors.size).isGreaterThan(0) + val expectedConnector1 = connectors.first() + val expectedConnector2 = connectors.last() + connectors.forEachIndexed { i, connector -> + if (connector !== expectedConnector1 && connector !== expectedConnector2) { + fail( + "connectors[$i]==$connector, " + + "but expected either $expectedConnector1 or $expectedConnector2" + ) + } + } + } +} diff --git a/firebase-dataconnect/emulator/.firebaserc b/firebase-dataconnect/emulator/.firebaserc new file mode 100644 index 00000000000..17ef85e0fa0 --- /dev/null +++ b/firebase-dataconnect/emulator/.firebaserc @@ -0,0 +1,10 @@ +{ + "projects": { + "default": "prjh5zbv64sv6" + }, + "dataconnectEmulatorConfig": { + "postgres": { + "localConnectionString": "postgresql://postgres:postgres@localhost:5432?sslmode=disable" + } + } +} diff --git a/firebase-dataconnect/emulator/.gitignore b/firebase-dataconnect/emulator/.gitignore new file mode 100644 index 00000000000..2fa0bec2101 --- /dev/null +++ b/firebase-dataconnect/emulator/.gitignore @@ -0,0 +1,7 @@ +.dataconnect/ +/cli +/*.tools.json +/firebase-debug.log +/.firebase/ +/ui-debug.log +/dataconnect-debug.log diff --git a/firebase-dataconnect/emulator/README.md b/firebase-dataconnect/emulator/README.md new file mode 100644 index 00000000000..4479d1ea99d --- /dev/null +++ b/firebase-dataconnect/emulator/README.md @@ -0,0 +1,258 @@ +# Firebase Data Connect Emulator Scripts + +This directory contains scripts for launching the Firebase Data Connect emulator +for the purposes of running the integration tests. + +Here is a summary of the detailed steps from below: +1. Compile the emulator in google3 by running one of the following commands: + - Linux: `blaze build //third_party/firebase/dataconnect/emulator/cli:cli` + - macOS Intel: `blaze build --config=darwin_x86_64 //third_party/firebase/dataconnect/emulator/cli:cli_macos` + - macOS Arm64: `blaze build --config=darwin_arm64 //third_party/firebase/dataconnect/emulator/cli:cli_macos` +2. Install `podman`, such as via homebrew: `brew install podman` +3. On macOS, initialize Podman's Linux VM: `podman machine init` +4. On macOS, start Podman's Linux VM: `podman machine start` +5. Start the Postgresql container: `./start_postgres_pod.sh` +6. Start the emulator: `./cli -alsologtostderr=1 -stderrthreshold=0 dev` + +## Step 1: Compile Firebase Data Connect Emulator + +Compile the Firebase Data Connect Emulator in google3 using `blaze`. +The build must be done in a gLinux workstation or go/cloudtop instance; +namely, building on a macOS host is not supported, even though macOS _is_ +supported as a _target_ platform. + +The exact command-line arguments for blaze depend on the target platform. +Supported targets platforms are: gLinux workstations or CloudTop instances and +Google-issued MacBooks (both intel and arm64 architectures). + +First, create a CITC or Fig workspace to perform the build. The instructions +below use CITC because it is simpler; the instructions, however, can be easily +adapted for Fig. + +1. `p4 citc dataconnect_emulator` +2. `cd /google/src/cloud/USERNAME/dataconnect_emulator/google3` + +#### Compile for Linux + +When building the emulator targetting a gLinux workstation or go/cloudtop +instance, build the `cli` target and do not specify `--config` (because the +host is the default target). + +``` +blaze build //third_party/firebase/dataconnect/emulator/cli:cli +``` + +If successful, the emulator binary will be located at + +``` +blaze-bin/third_party/firebase/dataconnect/emulator/cli/cli +``` + +#### Compile for macOS + +When building the emulator targetting macOS, build the `cli_macos` target +(instead of the `cli` target) and make sure to specify `--config=darwin_x86_64` +for an Intel MacBook or `--config=darwin_arm64` for an ARM-based MacBook. + +``` +blaze build --config=x86_64 //third_party/firebase/dataconnect/emulator/cli:cli +blaze build --config=darwin_arm64 //third_party/firebase/dataconnect/emulator/cli:cli_macos +``` + +If successful, the emulator binary will be located at + +``` +blaze-bin/third_party/firebase/dataconnect/emulator/cli/cli_macos +``` + +#### Copy Emulator Binary to Target Machine + +If the machine used to build the emulator binary is the same as the target +machine, then you are done. Otherwise, you need to copy the binary to the target +machine. There are two easy ways to do this: `scp` and `x20`. + +To use `scp`, run this command on the target machine to copy the binary into the +current directory. Replace `HOSTNAME` with the hostname of the build machine, +`USERNAME` with your username, and `cli` with `cli_macos` if the target machine +is macOS: + +``` +scp MACHINE:/google/src/cloud/USERNAME/dataconnect_emulator/google3/blaze-bin/third_party/firebase/dataconnect/emulator/cli/cli . +``` + +To use `x20`, run this command on the build machine to copy the emulator binary +into your private x20 directory. Replace `US` with the first two letter of your +username, `USERNAME` with your username, and `cli` with `cli_macos` if the +target machine is macOS: + +``` +cp blaze-bin/third_party/firebase/dataconnect/emulator/cli/cli /google/data/rw/users/US/USERNAME/ +``` + +On the target machine, navigate to http://x20/ in a web browser and download +the emulator binary by clicking on it. + +To share the compiled binary with others you will need to use a "teams" +directory in x20. See g3doc/company/teams/x20/user_documentation/sharing_files_on_x20.md +for details. + +In either case, you will likely need to change the permissions of the binary to +make it executable: + +``` +chmod a+x cli +``` + +or + +``` +chmod a+x cli_macos +``` + +#### Precompiled Emulator Binaries + +dconeybe maintains a directory with precompiled emulator binaries: + +http://x20/teams/firestore-clients/DataConnectEmulator + +At the time of writing, these builds incorporate the patch to remove vector +support, as mentioned in the "Troubleshooting" section below. + +## Step 2: Start Postgresql Server + +The Firebase Data Connect emulator requires a real Postgresql server to talk to. +Installing and configuring a Postgresql server on a given platform is a tedious +and non-standard process. Moreover, it is not consistent how the database's data +is cleared if you wanted to start afresh. + +Therefore, the instructions here document using a "Docker image" and its +containerization technology to run a Postgresql server in an isolated +environment that is easily started, stopped, and reset to a fresh state. +Using Docker (https://www.docker.com) would definitely work; however, Docker +is strongly discourgaged becuase it requires its daemon to run as root, a +massive security loophole. To work around this, a competing product named +"Podman" (https://podman.io) was born, and the instructions here use Podman +instead of Docker to avoid the unnecessary root daemon. +See go/dont-install-docker for more details on this. + +The instructions to setup and run podman are quite simple on Linux, and a little +mor involved on macOS. However, once setup, launching the container is as easy +as launching any other emulator. + +#### Install Podman (Linux) + +Installing Podman on gLinux workstations or CloudTop instances is as easy as +running this script: http://google3/experimental/users/superdanby/install-podman + +#### Install Podman (macOS) + +Installing Podman on MacBooks is easiest done via a package manager like +Homebrew. Since containerization technology is a Linux-specific feature, Podman +needs to start a Linux virtual machine in the background to actually _run_ the +containers. As such there are some additional steps required for macOS. + +To install Podman, run these commands: +1. `brew install podman` +2. `podman machine init` +3. `podman machine start` + +The "machine" commands create and start the Linux virtual machine, respectively. + +#### Launch the Postgresql Containers + +A handy helper script is all that is needed to start the Postgresql server: + +``` +./start_postgres_pod.sh +``` + +It is safe to run this command if the containers are already running (they will +just continue to run unaffected). + +The final output of the script shows some additional commands that can be run +to, for example, stop the Postgresql server and delete the Postgresql server's +database. + +There is also a Web UI called "pgadmin4" that can be used to visually interact +with the database. The URL and login credentials are included in the final lines +of output from the script. + +#### Launch the Data Connect Emulator + +With the Postgresql containers running, launch the Data Connect emulator with +this command: + +``` +./cli -alsologtostderr=1 -stderrthreshold=0 dev -local_connection_string='postgresql://postgres:postgres@localhost:5432/emulator?sslmode=disable' +``` + +You will likely see some errors in the log output, but most of them can be +safely ignored. At the time of writing, these errors are safe to ignore: + +* Anything from `codegen.go`, such as "ERROR - reading folder" and + "ERROR - error loading schema" +* "unable to walk dir" + +You definitely want to see lines like this: + +``` +UpdateSchema(): succeeds! +ClearConnectors(): succeeds! +UpdateConnector(person): succeeds! +UpdateConnector(posts): succeeds! +UpdateConnector(alltypes): succeeds! +``` + +Note that these log lines may change over time. Just know that some errors are +"normal" and others, especially those pertaining to loading the `.gql` files, +could indicate a real problem. + +## Troubleshooting + +#### Error: python@3.12: the bottle needs the Apple Command Line Tools... + +On macOS, if `brew install podman` gives an error like +"Error: python@3.12: the bottle needs the Apple Command Line Tools..." +then use the mitigation it suggests. +At the time of writing, the preferred mitigation was to install the missing +package by running `xcode-select --install`. If you don't have Xcode installed +at all, download the latest version from go/xcode and install it. + +#### Unable to load "vector" or "google_ml_integration" pogstresql extensions. + +Update (Mar 12, 2024): This should be fixed by cl/615215810, removing the need +for the workaround documented below. + +If you get an error like this in the Data Connect Emulator's output: + +``` +E0311 11:22:53.381764 1 load.go:45] Could not deploy schema: failed to force migrate SQL database: failed to execute extension installation: pq: extension "vector" is not available +SQL: CREATE EXTENSION IF NOT EXISTS "vector" +``` + +or + +``` +E0311 11:26:50.893660 1 load.go:45] Could not deploy schema: failed to force migrate SQL database: failed to execute extension installation: pq: extension "google_ml_integration" is not available +SQL: CREATE EXTENSION IF NOT EXISTS "google_ml_integration" CASCADE +``` + +then the workaround is to comment out the lines in the emulator's source code +that try to load these extensions. This will, obviously, preclude using vector +types, but as long as that is acceptable then this workaround works. + +To do this, comment out these lines from +`third_party/firebase/dataconnect/core/schema/migrate/plan.go`: + +``` +// TODO: b/319967793 - install vector extension only when db schema warrants it. +{Cmd: `CREATE EXTENSION IF NOT EXISTS "vector"`}, +// install google_ml_integration extension +{Cmd: `CREATE EXTENSION IF NOT EXISTS "google_ml_integration" CASCADE`}, +``` + +(http://google3/third_party/firebase/dataconnect/core/schema/migrate/plan.go;l=25-28;rcl=613618147) + +Then, recompile the emulator, as described above. + +See https://chat.google.com/room/AAAAdvEjzno/6pk_Mz7Hm5o and b/319967793 for details. diff --git a/firebase-dataconnect/emulator/dataconnect/.gitignore b/firebase-dataconnect/emulator/dataconnect/.gitignore new file mode 100644 index 00000000000..962b10fc816 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/.gitignore @@ -0,0 +1 @@ +/.generated/ diff --git a/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql new file mode 100644 index 00000000000..575c2d64dfa --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql @@ -0,0 +1,192 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +mutation createPrimitive( + $id: UUID!, + $idFieldNullable: UUID, + $intField: Int!, + $intFieldNullable: Int, + $floatField: Float!, + $floatFieldNullable: Float, + $booleanField: Boolean!, + $booleanFieldNullable: Boolean, + $stringField: String!, + $stringFieldNullable: String +) @auth(level: PUBLIC) { + primitive_insert(data: { + id: $id, + idFieldNullable: $idFieldNullable, + intField: $intField, + intFieldNullable: $intFieldNullable, + floatField: $floatField, + floatFieldNullable: $floatFieldNullable, + booleanField: $booleanField, + booleanFieldNullable: $booleanFieldNullable, + stringField: $stringField, + stringFieldNullable: $stringFieldNullable + }) +} + +query getPrimitive($id: UUID!) @auth(level: PUBLIC) { + primitive(id: $id) { + id + idFieldNullable + intField + intFieldNullable + floatField + floatFieldNullable + booleanField + booleanFieldNullable + stringField + stringFieldNullable + } +} + +mutation createPrimitiveList( + $id: UUID!, + $idListNullable: [UUID!], + $idListOfNullable: [UUID!], + $intList: [Int!]!, + $intListNullable: [Int!], + $intListOfNullable: [Int!], + $floatList: [Float!]!, + $floatListNullable: [Float!], + $floatListOfNullable: [Float!], + $booleanList: [Boolean!]!, + $booleanListNullable: [Boolean!], + $booleanListOfNullable: [Boolean!], + $stringList: [String!]!, + $stringListNullable: [String!], + $stringListOfNullable: [String!] +) @auth(level: PUBLIC) { + primitiveList_insert(data: { + id: $id, + idListNullable: $idListNullable, + idListOfNullable: $idListOfNullable, + intList: $intList, + intListNullable: $intListNullable, + intListOfNullable: $intListOfNullable, + floatList: $floatList, + floatListNullable: $floatListNullable, + floatListOfNullable: $floatListOfNullable, + booleanList: $booleanList, + booleanListNullable: $booleanListNullable, + booleanListOfNullable: $booleanListOfNullable, + stringList: $stringList, + stringListNullable: $stringListNullable, + stringListOfNullable: $stringListOfNullable + }) +} + +query getPrimitiveList($id: UUID!) @auth(level: PUBLIC) { + primitiveList(id: $id) { + id + idListNullable + idListOfNullable + intList + intListNullable + intListOfNullable + floatList + floatListNullable + floatListOfNullable + booleanList + booleanListNullable + booleanListOfNullable + stringList + stringListNullable + stringListOfNullable + } +} + +query getAllPrimitiveLists @auth(level: PUBLIC) { + primitiveLists { + id + idListNullable + idListOfNullable + intList + intListNullable + intListOfNullable + floatList + floatListNullable + floatListOfNullable + booleanList + booleanListNullable + booleanListOfNullable + stringList + stringListNullable + stringListOfNullable + } +} + +mutation createFarmer( + $id: String!, + $name: String!, + $parentId: String +) @auth(level: PUBLIC) { + farmer_insert(data: { + id: $id, + name: $name, + parentId: $parentId + }) +} + +mutation createAnimal( + $id: String!, + $farmId: String!, + $name: String!, + $species: String!, + $age: Int +) @auth(level: PUBLIC) { + animal_insert(data: { + id: $id, + farmId: $farmId, + name: $name, + species: $species, + age: $age + }) +} + +mutation createFarm( + $id: String!, + $name: String!, + $farmerId: String! +) @auth(level: PUBLIC) { + farm_insert(data: { + id: $id, + name: $name, + farmerId: $farmerId + }) +} + +query getFarm($id: String!) @auth(level: PUBLIC) { + farm(id: $id) { + id + name + farmer { + id + name + parent { + id + name + parentId + } + } + animals: animals_on_farm { + id + name + species + age + } + } +} diff --git a/firebase-dataconnect/emulator/dataconnect/connector/alltypes/connector.yaml b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/connector.yaml new file mode 100644 index 00000000000..f83fe1545c1 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/connector.yaml @@ -0,0 +1,2 @@ +connectorId: alltypes +authMode: PUBLIC diff --git a/firebase-dataconnect/emulator/dataconnect/connector/demo/connector.yaml b/firebase-dataconnect/emulator/dataconnect/connector/demo/connector.yaml new file mode 100644 index 00000000000..5a6476aa730 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/demo/connector.yaml @@ -0,0 +1,6 @@ +connectorId: demo +authMode: PUBLIC +generate: + kotlinSdk: + outputDir: ../../.generated/demo + package: com.google.firebase.dataconnect.connectors.demo diff --git a/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql new file mode 100644 index 00000000000..4f63cad042f --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql @@ -0,0 +1,1415 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +mutation InsertFoo($id: String!, $bar: String) + @auth(level: PUBLIC) { + foo_insert(data: {id: $id, bar: $bar}) +} + +mutation UpsertFoo($id: String!, $bar: String) + @auth(level: PUBLIC) { + foo_upsert(data: {id: $id, bar: $bar}) +} + +mutation DeleteFoo($id: String!) + @auth(level: PUBLIC) { + foo_delete(id: $id) +} + +mutation DeleteFoosByBar($bar: String!) + @auth(level: PUBLIC) { + foo_deleteMany(where: {bar: {eq: $bar}}) +} + +mutation UpdateFoo($id: String!, $newBar: String) + @auth(level: PUBLIC) { + foo_update(id: $id, data: {bar: $newBar}) +} + +mutation UpdateFoosByBar($oldBar: String, $newBar: String) + @auth(level: PUBLIC) { + foo_updateMany(where: {bar: {eq: $oldBar}}, data: {bar: $newBar}) +} + +query GetFooById($id: String!) + @auth(level: PUBLIC) { + foo(id: $id) { + bar + } +} + +query GetFoosByBar($bar: String) + @auth(level: PUBLIC) { + foos(where: {bar: {eq: $bar}}) { + id + } +} + +# This is an example mutation that has no variables, for testing purposes. +mutation UpsertHardcodedFoo + @auth(level: PUBLIC) { + foo_upsert(data: {id: "18e61f0a-8abc-4b18-9c4c-28c2f4e82c8f", bar: "BAR"}) +} + +# This is an example query that has no variables, for testing purposes. +query GetHardcodedFoo + @auth(level: PUBLIC) { + foo(id: "18e61f0a-8abc-4b18-9c4c-28c2f4e82c8f") { + bar + } +} + +mutation InsertStringVariants( + $nonNullWithNonEmptyValue: String!, + $nonNullWithEmptyValue: String!, + $nullableWithNullValue: String, + $nullableWithNonNullValue: String, + $nullableWithEmptyValue: String, +) @auth(level: PUBLIC) { + stringVariants_insert(data: { + nonNullWithNonEmptyValue: $nonNullWithNonEmptyValue, + nonNullWithEmptyValue: $nonNullWithEmptyValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithNonNullValue: $nullableWithNonNullValue, + nullableWithEmptyValue: $nullableWithEmptyValue, + }) +} + +mutation UpdateStringVariantsByKey( + $key: StringVariants_Key!, + $nonNullWithNonEmptyValue: String, + $nonNullWithEmptyValue: String, + $nullableWithNullValue: String, + $nullableWithNonNullValue: String, + $nullableWithEmptyValue: String, +) @auth(level: PUBLIC) { + stringVariants_update(key: $key, data: { + nonNullWithNonEmptyValue: $nonNullWithNonEmptyValue, + nonNullWithEmptyValue: $nonNullWithEmptyValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithNonNullValue: $nullableWithNonNullValue, + nullableWithEmptyValue: $nullableWithEmptyValue, + }) +} + +mutation InsertStringVariantsWithHardcodedDefaults( + $nonNullWithNonEmptyValue: String! = "pfnk98yqqs", + $nonNullWithEmptyValue: String! = "", + $nullableWithNullValue: String = null, + $nullableWithNonNullValue: String = "af8k72s98t", + $nullableWithEmptyValue: String = "", +) @auth(level: PUBLIC) { + stringVariants_insert(data: { + nonNullWithNonEmptyValue: $nonNullWithNonEmptyValue, + nonNullWithEmptyValue: $nonNullWithEmptyValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithNonNullValue: $nullableWithNonNullValue, + nullableWithEmptyValue: $nullableWithEmptyValue, + }) +} + +query GetStringVariantsByKey($key: StringVariants_Key!) @auth(level: PUBLIC) { + stringVariants(key: $key) { + nonNullWithNonEmptyValue + nonNullWithEmptyValue + nullableWithNullValue + nullableWithNonNullValue + nullableWithEmptyValue + } +} + +mutation InsertIntVariants( + $nonNullWithZeroValue: Int!, + $nonNullWithPositiveValue: Int!, + $nonNullWithNegativeValue: Int!, + $nonNullWithMaxValue: Int!, + $nonNullWithMinValue: Int!, + $nullableWithNullValue: Int, + $nullableWithZeroValue: Int, + $nullableWithPositiveValue: Int, + $nullableWithNegativeValue: Int, + $nullableWithMaxValue: Int, + $nullableWithMinValue: Int, +) @auth(level: PUBLIC) { + intVariants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +mutation InsertIntVariantsWithHardcodedDefaults( + $nonNullWithZeroValue: Int! = 0, + $nonNullWithPositiveValue: Int! = 819425, + $nonNullWithNegativeValue: Int! = -435970, + $nonNullWithMaxValue: Int! = 2147483647, + $nonNullWithMinValue: Int! = -2147483648, + $nullableWithNullValue: Int = null, + $nullableWithZeroValue: Int = 0, + $nullableWithPositiveValue: Int = 635166, + $nullableWithNegativeValue: Int = -171993, + $nullableWithMaxValue: Int = 2147483647, + $nullableWithMinValue: Int = -2147483648, +) @auth(level: PUBLIC) { + intVariants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +mutation UpdateIntVariantsByKey( + $key: IntVariants_Key!, + $nonNullWithZeroValue: Int, + $nonNullWithPositiveValue: Int, + $nonNullWithNegativeValue: Int, + $nonNullWithMaxValue: Int, + $nonNullWithMinValue: Int, + $nullableWithNullValue: Int, + $nullableWithZeroValue: Int, + $nullableWithPositiveValue: Int, + $nullableWithNegativeValue: Int, + $nullableWithMaxValue: Int, + $nullableWithMinValue: Int, +) @auth(level: PUBLIC) { + intVariants_update(key: $key, data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +query GetIntVariantsByKey($key: IntVariants_Key!) @auth(level: PUBLIC) { + intVariants(key: $key) { + nonNullWithZeroValue + nonNullWithPositiveValue + nonNullWithNegativeValue + nonNullWithMaxValue + nonNullWithMinValue + nullableWithNullValue + nullableWithZeroValue + nullableWithPositiveValue + nullableWithNegativeValue + nullableWithMaxValue + nullableWithMinValue + } +} + +mutation InsertFloatVariants( + $nonNullWithZeroValue: Float!, + $nonNullWithNegativeZeroValue: Float!, + $nonNullWithPositiveValue: Float!, + $nonNullWithNegativeValue: Float!, + $nonNullWithMaxValue: Float!, + $nonNullWithMinValue: Float!, + $nonNullWithMaxSafeIntegerValue: Float!, + $nullableWithNullValue: Float, + $nullableWithZeroValue: Float, + $nullableWithNegativeZeroValue: Float, + $nullableWithPositiveValue: Float, + $nullableWithNegativeValue: Float, + $nullableWithMaxValue: Float, + $nullableWithMinValue: Float, + $nullableWithMaxSafeIntegerValue: Float, +) @auth(level: PUBLIC) { + floatVariants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithNegativeZeroValue: $nonNullWithNegativeZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nonNullWithMaxSafeIntegerValue: $nonNullWithMaxSafeIntegerValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithNegativeZeroValue: $nullableWithNegativeZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + nullableWithMaxSafeIntegerValue: $nullableWithMaxSafeIntegerValue, + }) +} + +mutation InsertFloatVariantsWithHardcodedDefaults( + $nonNullWithZeroValue: Float! = 0.0, + $nonNullWithNegativeZeroValue: Float! = -0.0, + $nonNullWithPositiveValue: Float! = 750.452, + $nonNullWithNegativeValue: Float! = -598.351, + $nonNullWithMaxValue: Float! = 1.7976931348623157E308, + $nonNullWithMinValue: Float! = 4.9E-324, + $nonNullWithMaxSafeIntegerValue: Float! = 9007199254740991.0, + $nullableWithNullValue: Float = null, + $nullableWithZeroValue: Float = 0.0, + $nullableWithNegativeZeroValue: Float = -0.0, + $nullableWithPositiveValue: Float = 597.650, + $nullableWithNegativeValue: Float = -181.366, + $nullableWithMaxValue: Float = 1.7976931348623157E308, + $nullableWithMinValue: Float = 4.9E-324, + $nullableWithMaxSafeIntegerValue: Float = 9007199254740991.0, +) @auth(level: PUBLIC) { + floatVariants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithNegativeZeroValue: $nonNullWithNegativeZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nonNullWithMaxSafeIntegerValue: $nonNullWithMaxSafeIntegerValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithNegativeZeroValue: $nullableWithNegativeZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + nullableWithMaxSafeIntegerValue: $nullableWithMaxSafeIntegerValue, + }) +} + +mutation UpdateFloatVariantsByKey( + $key: FloatVariants_Key!, + $nonNullWithZeroValue: Float, + $nonNullWithNegativeZeroValue: Float, + $nonNullWithPositiveValue: Float, + $nonNullWithNegativeValue: Float, + $nonNullWithMaxValue: Float, + $nonNullWithMinValue: Float, + $nonNullWithMaxSafeIntegerValue: Float, + $nullableWithNullValue: Float, + $nullableWithZeroValue: Float, + $nullableWithNegativeZeroValue: Float, + $nullableWithPositiveValue: Float, + $nullableWithNegativeValue: Float, + $nullableWithMaxValue: Float, + $nullableWithMinValue: Float, + $nullableWithMaxSafeIntegerValue: Float, +) @auth(level: PUBLIC) { + floatVariants_update(key: $key, data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithNegativeZeroValue: $nonNullWithNegativeZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nonNullWithMaxSafeIntegerValue: $nonNullWithMaxSafeIntegerValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithNegativeZeroValue: $nullableWithNegativeZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + nullableWithMaxSafeIntegerValue: $nullableWithMaxSafeIntegerValue, + }) +} + +query GetFloatVariantsByKey($key: FloatVariants_Key!) @auth(level: PUBLIC) { + floatVariants(key: $key) { + nonNullWithZeroValue + nonNullWithNegativeZeroValue + nonNullWithPositiveValue + nonNullWithNegativeValue + nonNullWithMaxValue + nonNullWithMinValue + nonNullWithMaxSafeIntegerValue + nullableWithNullValue + nullableWithZeroValue + nullableWithNegativeZeroValue + nullableWithPositiveValue + nullableWithNegativeValue + nullableWithMaxValue + nullableWithMinValue + nullableWithMaxSafeIntegerValue + } +} + +mutation InsertBooleanVariants( + $nonNullWithTrueValue: Boolean!, + $nonNullWithFalseValue: Boolean!, + $nullableWithNullValue: Boolean, + $nullableWithTrueValue: Boolean, + $nullableWithFalseValue: Boolean, +) @auth(level: PUBLIC) { + booleanVariants_insert(data: { + nonNullWithTrueValue: $nonNullWithTrueValue, + nonNullWithFalseValue: $nonNullWithFalseValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithTrueValue: $nullableWithTrueValue, + nullableWithFalseValue: $nullableWithFalseValue, + }) +} + +mutation InsertBooleanVariantsWithHardcodedDefaults( + $nonNullWithTrueValue: Boolean! = true, + $nonNullWithFalseValue: Boolean! = false, + $nullableWithNullValue: Boolean = null, + $nullableWithTrueValue: Boolean = true, + $nullableWithFalseValue: Boolean = false, +) @auth(level: PUBLIC) { + booleanVariants_insert(data: { + nonNullWithTrueValue: $nonNullWithTrueValue, + nonNullWithFalseValue: $nonNullWithFalseValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithTrueValue: $nullableWithTrueValue, + nullableWithFalseValue: $nullableWithFalseValue, + }) +} + +mutation UpdateBooleanVariantsByKey( + $key: BooleanVariants_Key!, + $nonNullWithTrueValue: Boolean, + $nonNullWithFalseValue: Boolean, + $nullableWithNullValue: Boolean, + $nullableWithTrueValue: Boolean, + $nullableWithFalseValue: Boolean, +) @auth(level: PUBLIC) { + booleanVariants_update(key: $key, data: { + nonNullWithTrueValue: $nonNullWithTrueValue, + nonNullWithFalseValue: $nonNullWithFalseValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithTrueValue: $nullableWithTrueValue, + nullableWithFalseValue: $nullableWithFalseValue, + }) +} + +query GetBooleanVariantsByKey($key: BooleanVariants_Key!) @auth(level: PUBLIC) { + booleanVariants(key: $key) { + nonNullWithTrueValue + nonNullWithFalseValue + nullableWithNullValue + nullableWithTrueValue + nullableWithFalseValue + } +} + +mutation InsertInt64Variants( + $nonNullWithZeroValue: Int64!, + $nonNullWithPositiveValue: Int64!, + $nonNullWithNegativeValue: Int64!, + $nonNullWithMaxValue: Int64!, + $nonNullWithMinValue: Int64!, + $nullableWithNullValue: Int64, + $nullableWithZeroValue: Int64, + $nullableWithPositiveValue: Int64, + $nullableWithNegativeValue: Int64, + $nullableWithMaxValue: Int64, + $nullableWithMinValue: Int64, +) @auth(level: PUBLIC) { + int64Variants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +mutation InsertInt64VariantsWithHardcodedDefaults( + $nonNullWithZeroValue: Int64! = 0, + $nonNullWithPositiveValue: Int64! = 8140262498000722655, + $nonNullWithNegativeValue: Int64! = -6722404680598014256, + $nonNullWithMaxValue: Int64! = 9223372036854775807, + $nonNullWithMinValue: Int64! = -9223372036854775808, + $nullableWithNullValue: Int64 = null, + $nullableWithZeroValue: Int64 = 0, + $nullableWithPositiveValue: Int64 = 2623421399624774761, + $nullableWithNegativeValue: Int64 = -1400927531111898547, + $nullableWithMaxValue: Int64 = 9223372036854775807, + $nullableWithMinValue: Int64 = -9223372036854775808, +) @auth(level: PUBLIC) { + int64Variants_insert(data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +mutation UpdateInt64VariantsByKey( + $key: Int64Variants_Key!, + $nonNullWithZeroValue: Int64, + $nonNullWithPositiveValue: Int64, + $nonNullWithNegativeValue: Int64, + $nonNullWithMaxValue: Int64, + $nonNullWithMinValue: Int64, + $nullableWithNullValue: Int64, + $nullableWithZeroValue: Int64, + $nullableWithPositiveValue: Int64, + $nullableWithNegativeValue: Int64, + $nullableWithMaxValue: Int64, + $nullableWithMinValue: Int64, +) @auth(level: PUBLIC) { + int64Variants_update(key: $key, data: { + nonNullWithZeroValue: $nonNullWithZeroValue, + nonNullWithPositiveValue: $nonNullWithPositiveValue, + nonNullWithNegativeValue: $nonNullWithNegativeValue, + nonNullWithMaxValue: $nonNullWithMaxValue, + nonNullWithMinValue: $nonNullWithMinValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithZeroValue: $nullableWithZeroValue, + nullableWithPositiveValue: $nullableWithPositiveValue, + nullableWithNegativeValue: $nullableWithNegativeValue, + nullableWithMaxValue: $nullableWithMaxValue, + nullableWithMinValue: $nullableWithMinValue, + }) +} + +query GetInt64VariantsByKey($key: Int64Variants_Key!) @auth(level: PUBLIC) { + int64Variants(key: $key) { + nonNullWithZeroValue + nonNullWithPositiveValue + nonNullWithNegativeValue + nonNullWithMaxValue + nonNullWithMinValue + nullableWithNullValue + nullableWithZeroValue + nullableWithPositiveValue + nullableWithNegativeValue + nullableWithMaxValue + nullableWithMinValue + } +} + +mutation InsertUUIDVariants( + $nonNullValue: UUID!, + $nullableWithNullValue: UUID, + $nullableWithNonNullValue: UUID, +) @auth(level: PUBLIC) { + uUIDVariants_insert(data: { + nonNullValue: $nonNullValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithNonNullValue: $nullableWithNonNullValue, + }) +} + +# TODO(b/341070491) Uncomment this mutation and enable test "insertUUIDVariantsWithDefaultValues" +# At the time of writing, default values for UUID variables fails to generate valid SQL. +# Once fixed, uncomment the definition of "InsertUUIDVariantsWithHardcodedDefaults" below +#mutation InsertUUIDVariantsWithHardcodedDefaults( +# $nonNullValue: UUID! = "66576fdc-1a35-4b59-8c8b-d3beb65956ca", +# $nullableWithNullValue: UUID = null, +# $nullableWithNonNullValue: UUID = "59ab3886-8b84-4233-a5e6-da58c0e8b97d", +#) @auth(level: PUBLIC) { +# uUIDVariants_insert(data: { +# nonNullValue: $nonNullValue, +# nullableWithNullValue: $nullableWithNullValue, +# nullableWithNonNullValue: $nullableWithNonNullValue, +# }) +#} + +# TODO(b/341070491) Delete this definition of the "InsertUUIDVariantsWithHardcodedDefaults" mutation +# and uncomment the one above once the emulator is fixed to properly handle default values for UUID +# variables. This definition is merely here so that the codegen will generate classes so that the +# test can still be written in Kotlin and compile, even though it is disabled. +mutation InsertUUIDVariantsWithHardcodedDefaults @auth(level: PUBLIC) { + uUIDVariants_insert(data: { + nonNullValue: "7f385800-13d6-491a-98c8-b4dee3fb45cb", + nullableWithNullValue: null, + nullableWithNonNullValue: "ede8dc0e-600c-4093-8812-071f2cedc8db", + }) +} + +mutation UpdateUUIDVariantsByKey( + $key: UUIDVariants_Key!, + $nonNullValue: UUID, + $nullableWithNullValue: UUID, + $nullableWithNonNullValue: UUID, +) @auth(level: PUBLIC) { + uUIDVariants_update(key: $key, data: { + nonNullValue: $nonNullValue, + nullableWithNullValue: $nullableWithNullValue, + nullableWithNonNullValue: $nullableWithNonNullValue, + }) +} + +query GetUUIDVariantsByKey($key: UUIDVariants_Key!) @auth(level: PUBLIC) { + uUIDVariants(key: $key) { + nonNullValue + nullableWithNullValue + nullableWithNonNullValue + } +} + +mutation InsertSyntheticId($value: String!) @auth(level: PUBLIC) { + syntheticId_insert(data: { value: $value }) +} + +query GetSyntheticIdById($id: UUID!) @auth(level: PUBLIC) { + syntheticId(id: $id) { id value } +} + +mutation InsertPrimaryKeyIsString($id: String!, $value: String!) @auth(level: PUBLIC) { + primaryKeyIsString_insert(data: { + id: $id, + value: $value + }) +} + +query GetPrimaryKeyIsStringByKey($key: PrimaryKeyIsString_Key!) @auth(level: PUBLIC) { + primaryKeyIsString(key: $key) { id value } +} + +mutation InsertPrimaryKeyIsUUID($id: UUID!, $value: String!) @auth(level: PUBLIC) { + primaryKeyIsUUID_insert(data: { + id: $id, + value: $value + }) +} + +query GetPrimaryKeyIsUUIDByKey($key: PrimaryKeyIsUUID_Key!) @auth(level: PUBLIC) { + primaryKeyIsUUID(key: $key) { id value } +} + +mutation InsertPrimaryKeyIsInt($foo: Int!, $value: String!) @auth(level: PUBLIC) { + # NOTE: Use "upsert" instead of "insert" in this specific case since Int only has a 32-bit + # representation, increasing the likelihood of conflicts. In the unlikely case of an ID being + # used that already exists, it will just be replaced. There is a minuscule chance that the test + # that generated the conflicting ID is running concurrently, but that chance is so small that I'm + # choosing to ignore it. + primaryKeyIsInt_upsert(data: { + foo: $foo, + value: $value + }) +} + +query GetPrimaryKeyIsIntByKey($key: PrimaryKeyIsInt_Key!) @auth(level: PUBLIC) { + primaryKeyIsInt(key: $key) { foo value } +} + +mutation InsertPrimaryKeyIsFloat($foo: Float!, $value: String!) @auth(level: PUBLIC) { + # NOTE: Use "upsert" instead of "insert" in this specific case since Float values are generated + # using a pseudo-random number generator, which has a non-zero chance of conflicts. In the + # unlikely case of an value being used that already exists, it will just be replaced. There is a + # minuscule chance that the test that generated the conflicting ID is running concurrently, but + # that chance is so small that I'm choosing to ignore it. + primaryKeyIsFloat_upsert(data: { + foo: $foo, + value: $value + }) +} + +query GetPrimaryKeyIsFloatByKey($key: PrimaryKeyIsFloat_Key!) @auth(level: PUBLIC) { + primaryKeyIsFloat(key: $key) { foo value } +} + +mutation InsertPrimaryKeyIsDate($foo: Date!, $value: String!) @auth(level: PUBLIC) { + # NOTE: Use "upsert" instead of "insert" in this specific case since Date values are generated + # using a pseudo-random number generator, which has a non-zero chance of conflicts. In the + # unlikely case of an value being used that already exists, it will just be replaced. There is a + # minuscule chance that the test that generated the conflicting ID is running concurrently, but + # that chance is so small that I'm choosing to ignore it. + primaryKeyIsDate_upsert(data: { + foo: $foo, + value: $value + }) +} + +query GetPrimaryKeyIsDateByKey($key: PrimaryKeyIsDate_Key!) @auth(level: PUBLIC) { + primaryKeyIsDate(key: $key) { foo value } +} + +mutation InsertPrimaryKeyIsTimestamp($foo: Timestamp!, $value: String!) @auth(level: PUBLIC) { + # NOTE: Use "upsert" instead of "insert" in this specific case since Timestamp values are + # generated using a pseudo-random number generator, which has a non-zero chance of conflicts. In + # the unlikely case of an value being used that already exists, it will just be replaced. There is + # a minuscule chance that the test that generated the conflicting ID is running concurrently, but + # that chance is so small that I'm choosing to ignore it. + primaryKeyIsTimestamp_upsert(data: { + foo: $foo, + value: $value + }) +} + +query GetPrimaryKeyIsTimestampByKey($key: PrimaryKeyIsTimestamp_Key!) @auth(level: PUBLIC) { + primaryKeyIsTimestamp(key: $key) { foo value } +} + +mutation InsertPrimaryKeyIsInt64($foo: Int64!, $value: String!) @auth(level: PUBLIC) { + primaryKeyIsInt64_upsert(data: { + foo: $foo, + value: $value + }) +} + +query GetPrimaryKeyIsInt64ByKey($key: PrimaryKeyIsInt64_Key!) @auth(level: PUBLIC) { + primaryKeyIsInt64(key: $key) { foo value } +} + +mutation InsertPrimaryKeyIsComposite($foo: Int!, $bar: String!, $baz: Boolean!, $value: String!) @auth(level: PUBLIC) { + primaryKeyIsComposite_insert(data: { + foo: $foo, + bar: $bar, + baz: $baz, + value: $value + }) +} + +query GetPrimaryKeyIsCompositeByKey($key: PrimaryKeyIsComposite_Key!) @auth(level: PUBLIC) { + primaryKeyIsComposite(key: $key) { foo bar baz value } +} + +mutation InsertPrimaryKeyNested1($value: String!) @auth(level: PUBLIC) { + primaryKeyNested1_insert(data: { value: $value } ) +} + +mutation InsertPrimaryKeyNested2($value: String!) @auth(level: PUBLIC) { + primaryKeyNested2_insert(data: { value: $value } ) +} + +mutation InsertPrimaryKeyNested3($value: String!) @auth(level: PUBLIC) { + primaryKeyNested3_insert(data: { value: $value } ) +} + +mutation InsertPrimaryKeyNested4($value: String!) @auth(level: PUBLIC) { + primaryKeyNested4_insert(data: { value: $value } ) +} + +mutation InsertPrimaryKeyNested5( + $value: String!, + $nested1: PrimaryKeyNested1_Key!, + $nested2: PrimaryKeyNested2_Key! +) @auth(level: PUBLIC) { + primaryKeyNested5_insert(data: { + value: $value, + nested1: $nested1, + nested2: $nested2 + }) +} + +mutation InsertPrimaryKeyNested6( + $value: String!, + $nested3: PrimaryKeyNested3_Key!, + $nested4: PrimaryKeyNested4_Key! +) @auth(level: PUBLIC) { + primaryKeyNested6_insert(data: { + value: $value, + nested3: $nested3, + nested4: $nested4 + }) +} + +mutation InsertPrimaryKeyNested7( + $value: String!, + $nested5a: PrimaryKeyNested5_Key!, + $nested5b: PrimaryKeyNested5_Key!, + $nested6: PrimaryKeyNested6_Key! +) @auth(level: PUBLIC) { + primaryKeyNested7_insert(data: { + value: $value, + nested5a: $nested5a, + nested5b: $nested5b, + nested6: $nested6 + }) +} + +query GetPrimaryKeyNested7ByKey($key: PrimaryKeyNested7_Key!) @auth(level: PUBLIC) { + primaryKeyNested7(key: $key) { + value + nested5a { + value + nested1 { + id + value + } + nested2 { + id + value + } + } + nested5b { + value + nested1 { + id + value + } + nested2 { + id + value + } + } + nested6 { + value + nested3 { + id + value + } + nested4 { + id + value + } + } + } +} + +mutation InsertNested1( + $nested1: Nested1_Key, + $nested2: Nested2_Key!, + $nested2NullableNonNull: Nested2_Key, + $nested2NullableNull: Nested2_Key, + $value: String! +) @auth(level: PUBLIC) { + nested1_insert(data: { + nested1: $nested1, + nested2: $nested2, + nested2NullableNonNull: $nested2NullableNonNull, + nested2NullableNull: $nested2NullableNull, + value: $value + }) +} + +query GetNested1ByKey($key: Nested1_Key!) @auth(level: PUBLIC) { + nested1(key: $key) { + id + nested1 { + id + nested1 { id } + nested2 { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + nested2NullableNull { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + nested2NullableNonNull { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + } + nested2 { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + nested2NullableNonNull { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + nested2NullableNull { + id + value + nested3 { + id + value + } + nested3NullableNull { + id + value + } + nested3NullableNonNull { + id + value + } + } + } +} + +mutation InsertNested2( + $nested3: Nested3_Key!, + $nested3NullableNonNull: Nested3_Key, + $nested3NullableNull: Nested3_Key, + $value: String! +) @auth(level: PUBLIC) { + nested2_insert(data: { + nested3: $nested3, + nested3NullableNonNull: $nested3NullableNonNull, + nested3NullableNull: $nested3NullableNull, + value: $value + }) +} + +mutation InsertNested3($value: String!) @auth(level: PUBLIC) { + nested3_insert(data: { value: $value }) +} + +mutation InsertManyToOneParent($child: ManyToOneChild_Key) @auth(level: PUBLIC) { + manyToOneParent_insert(data: { child: $child }) +} + +mutation InsertManyToOneChild @auth(level: PUBLIC) { + manyToOneChild_insert(data: {value: null}) +} + +query GetManyToOneChildByKey($key: ManyToOneChild_Key!) @auth(level: PUBLIC) { + manyToOneChild(key: $key) { + parents: manyToOneParents_on_child { + id + } + } +} + +mutation InsertManyToManyChildA @auth(level: PUBLIC) { + manyToManyChildA_insert(data: {}) +} + +mutation InsertManyToManyChildB @auth(level: PUBLIC) { + manyToManyChildB_insert(data: {}) +} + +mutation InsertManyToManyParent($childA: ManyToManyChildA_Key!, $childB: ManyToManyChildB_Key!) @auth(level: PUBLIC) { + manyToManyParent_insert(data: {childA: $childA, childB: $childB}) +} + +query GetManyToManyChildAByKey($key: ManyToManyChildA_Key!) @auth(level: PUBLIC) { + manyToManyChildA(key: $key) { + manyToManyChildBS_via_ManyToManyParent { + id + } + } +} + +mutation InsertManyToOneSelfCustomName($ref: ManyToOneSelfCustomName_Key) @auth(level: PUBLIC) { + manyToOneSelfCustomName_insert(data: {ref: $ref}) +} + +query GetManyToOneSelfCustomNameByKey($key: ManyToOneSelfCustomName_Key!) @auth(level: PUBLIC) { + manyToOneSelfCustomName(key: $key) { + id + ref { + id + refId + } + } +} + +mutation InsertManyToOneSelfMatchingName($ref: ManyToOneSelfMatchingName_Key) @auth(level: PUBLIC) { + manyToOneSelfMatchingName_insert(data: {manyToOneSelfMatchingName: $ref}) +} + +query GetManyToOneSelfMatchingNameByKey($key: ManyToOneSelfMatchingName_Key!) @auth(level: PUBLIC) { + manyToOneSelfMatchingName(key: $key) { + id + manyToOneSelfMatchingName { + id + manyToOneSelfMatchingNameId + } + } +} + +mutation InsertManyToManySelfParent($child1: ManyToManySelfChild_Key!, $child2: ManyToManySelfChild_Key!) @auth(level: PUBLIC) { + manyToManySelfParent_insert(data: {child1: $child1, child2: $child2}) +} + +mutation InsertManyToManySelfChild @auth(level: PUBLIC) { + manyToManySelfChild_insert(data: {}) +} + +query GetManyToManySelfChildByKey($key: ManyToManySelfChild_Key!) @auth(level: PUBLIC) { + manyToManySelfChild(key: $key) { + manyToManySelfChildren_via_ManyToManySelfParent_on_child1 { id } + manyToManySelfChildren_via_ManyToManySelfParent_on_child2 { id } + } +} + +mutation InsertOptionalStrings( + $required1: String!, + $required2: String!, + $nullable1: String, + $nullable2: String, + $nullable3: String, + $nullableWithSchemaDefault: String, +) @auth(level: PUBLIC) { + optionalStrings_insert(data: { + required1: $required1, + required2: $required2, + nullable1: $nullable1, + nullable2: $nullable2, + nullable3: $nullable3, + nullableWithSchemaDefault: $nullableWithSchemaDefault, + }) +} + +query GetOptionalStringsByKey($key: OptionalStrings_Key!) @auth(level: PUBLIC) { + optionalStrings(key: $key) { + required1 + required2 + nullable1 + nullable2 + nullable3 + nullableWithSchemaDefault + } +} + +mutation InsertNonNullableLists( + $strings: [String!]!, + $ints: [Int!]!, + $floats: [Float!]!, + $booleans: [Boolean!]!, + $uuids: [UUID!]!, + $int64s: [Int64!]!, + $dates: [Date!]!, + $timestamps: [Timestamp!]!, +) @auth(level: PUBLIC) { + nonNullableLists_insert(data: { + strings: $strings, + ints: $ints, + floats: $floats, + booleans: $booleans, + uuids: $uuids, + int64s: $int64s, + dates: $dates, + timestamps: $timestamps, + }) +} + +mutation UpdateNonNullableListsByKey( + $key: NonNullableLists_Key!, + $strings: [String!], + $ints: [Int!], + $floats: [Float!], + $booleans: [Boolean!], + $uuids: [UUID!], + $int64s: [Int64!], + $dates: [Date!], + $timestamps: [Timestamp!], +) @auth(level: PUBLIC) { + nonNullableLists_update(key: $key, data: { + strings: $strings, + ints: $ints, + floats: $floats, + booleans: $booleans, + uuids: $uuids, + int64s: $int64s, + dates: $dates, + timestamps: $timestamps, + }) +} + +query GetNonNullableListsByKey($key: NonNullableLists_Key!) @auth(level: PUBLIC) { + nonNullableLists(key: $key) { + strings + ints + floats + booleans + uuids + int64s + dates + timestamps + } +} + +mutation InsertNullableLists( + $strings: [String!], + $ints: [Int!], + $floats: [Float!], + $booleans: [Boolean!], + $uuids: [UUID!], + $int64s: [Int64!], + $dates: [Date!], + $timestamps: [Timestamp!], +) @auth(level: PUBLIC) { + nullableLists_insert(data: { + strings: $strings, + ints: $ints, + floats: $floats, + booleans: $booleans, + uuids: $uuids, + int64s: $int64s, + dates: $dates, + timestamps: $timestamps, + }) +} + +mutation UpdateNullableListsByKey( + $key: NullableLists_Key!, + $strings: [String!], + $ints: [Int!], + $floats: [Float!], + $booleans: [Boolean!], + $uuids: [UUID!], + $int64s: [Int64!], + $dates: [Date!], + $timestamps: [Timestamp!], +) @auth(level: PUBLIC) { + nullableLists_update(key: $key, data: { + strings: $strings, + ints: $ints, + floats: $floats, + booleans: $booleans, + uuids: $uuids, + int64s: $int64s, + dates: $dates, + timestamps: $timestamps, + }) +} + +query GetNullableListsByKey($key: NullableLists_Key!) @auth(level: PUBLIC) { + nullableLists(key: $key) { + strings + ints + floats + booleans + uuids + int64s + dates + timestamps + } +} + +mutation InsertNonNullDate($value: Date!) @auth(level: PUBLIC) { + nonNullDate_insert(data: { value: $value }) +} + +mutation UpdateNonNullDate($key: NonNullDate_Key!, $value: Date) @auth(level: PUBLIC) { + nonNullDate_update(key: $key, data: { value: $value }) +} + +query GetNonNullDateByKey($key: NonNullDate_Key!) @auth(level: PUBLIC) { + value: nonNullDate(key: $key) { value } +} + +mutation InsertNonNullDatesWithDefaults($value: Date! = "6904-11-30") @auth(level: PUBLIC) { + nonNullDatesWithDefaults_insert(data: { valueWithVariableDefault: $value }) +} + +query GetNonNullDatesWithDefaultsByKey($key: NonNullDatesWithDefaults_Key!) @auth(level: PUBLIC) { + nonNullDatesWithDefaults(key: $key) { + valueWithVariableDefault + valueWithSchemaDefault + epoch + requestTime1 + requestTime2 + } +} + +mutation InsertNullableDate($value: Date) @auth(level: PUBLIC) { + nullableDate_insert(data: { value: $value }) +} + +mutation UpdateNullableDate($key: NullableDate_Key!, $value: Date) @auth(level: PUBLIC) { + nullableDate_update(key: $key, data: { value: $value }) +} + +query GetNullableDateByKey($key: NullableDate_Key!) @auth(level: PUBLIC) { + value: nullableDate(key: $key) { value } +} + +mutation InsertNullableDatesWithDefaults($value: Date = "8113-02-09") @auth(level: PUBLIC) { + nullableDatesWithDefaults_insert(data: { valueWithVariableDefault: $value }) +} + +query GetNullableDatesWithDefaultsByKey($key: NullableDatesWithDefaults_Key!) @auth(level: PUBLIC) { + nullableDatesWithDefaults(key: $key) { + valueWithVariableDefault + valueWithSchemaDefault + epoch + requestTime1 + requestTime2 + } +} + +mutation InsertNonNullTimestamp($value: Timestamp!) @auth(level: PUBLIC) { + nonNullTimestamp_insert(data: { value: $value }) +} + +mutation UpdateNonNullTimestamp($key: NonNullTimestamp_Key!, $value: Timestamp) @auth(level: PUBLIC) { + nonNullTimestamp_update(key: $key, data: { value: $value }) +} + +query GetNonNullTimestampByKey($key: NonNullTimestamp_Key!) @auth(level: PUBLIC) { + value: nonNullTimestamp(key: $key) { value } +} + +mutation InsertNonNullTimestampsWithDefaults($value: Timestamp! = "3575-04-12T10:11:12.541991Z") @auth(level: PUBLIC) { + nonNullTimestampsWithDefaults_insert(data: { valueWithVariableDefault: $value }) +} + +query GetNonNullTimestampsWithDefaultsByKey($key: NonNullTimestampsWithDefaults_Key!) @auth(level: PUBLIC) { + nonNullTimestampsWithDefaults(key: $key) { + valueWithVariableDefault + valueWithSchemaDefault + epoch + requestTime1 + requestTime2 + } +} + +mutation InsertNullableTimestamp($value: Timestamp) @auth(level: PUBLIC) { + nullableTimestamp_insert(data: { value: $value }) +} + +mutation UpdateNullableTimestamp($key: NullableTimestamp_Key!, $value: Timestamp) @auth(level: PUBLIC) { + nullableTimestamp_update(key: $key, data: { value: $value }) +} + +query GetNullableTimestampByKey($key: NullableTimestamp_Key!) @auth(level: PUBLIC) { + value: nullableTimestamp(key: $key) { value } +} + +mutation InsertNullableTimestampsWithDefaults($value: Timestamp = "2554-12-20T13:03:45.110429Z") @auth(level: PUBLIC) { + nullableTimestampsWithDefaults_insert(data: { valueWithVariableDefault: $value }) +} + +query GetNullableTimestampsWithDefaultsByKey($key: NullableTimestampsWithDefaults_Key!) @auth(level: PUBLIC) { + nullableTimestampsWithDefaults(key: $key) { + valueWithVariableDefault + valueWithSchemaDefault + epoch + requestTime1 + requestTime2 + } +} + +############################################################################### +# Operations for table: AnyScalarNonNullable +############################################################################### + +mutation AnyScalarNonNullableInsert($tag: String, $value: Any!) @auth(level: PUBLIC) { + key: anyScalarNonNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNonNullableInsert3($tag: String, $value1: Any!, $value2: Any!, $value3: Any!) @auth(level: PUBLIC) { + key1: anyScalarNonNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNonNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNonNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNonNullableGetByKey($key: AnyScalarNonNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNonNullable(key: $key) { value } +} + +query AnyScalarNonNullableGetAllByTagAndValue($tag: String, $value: Any!) @auth(level: PUBLIC) { + items: anyScalarNonNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +############################################################################### +# Operations for table: AnyScalarNullable +############################################################################### + +mutation AnyScalarNullableInsert($tag: String, $value: Any) @auth(level: PUBLIC) { + key: anyScalarNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNullableInsert3($tag: String, $value1: Any, $value2: Any, $value3: Any) @auth(level: PUBLIC) { + key1: anyScalarNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNullableGetByKey($key: AnyScalarNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNullable(key: $key) { value } +} + +query AnyScalarNullableGetAllByTagAndValue($tag: String, $value: Any) @auth(level: PUBLIC) { + items: anyScalarNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +############################################################################### +# Operations for table: AnyScalarNullableListOfNullable +############################################################################### + +mutation AnyScalarNullableListOfNullableInsert($tag: String, $value: [Any!]) @auth(level: PUBLIC) { + key: anyScalarNullableListOfNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNullableListOfNullableInsert3($tag: String, $value1: [Any!], $value2: [Any!], $value3: [Any!]) @auth(level: PUBLIC) { + key1: anyScalarNullableListOfNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNullableListOfNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNullableListOfNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNullableListOfNullableGetByKey($key: AnyScalarNullableListOfNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNullableListOfNullable(key: $key) { value } +} + +query AnyScalarNullableListOfNullableGetAllByTagAndValue($tag: String, $value: [Any!]) @auth(level: PUBLIC) { + items: anyScalarNullableListOfNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { includesAll: $value }, tag: { eq: $tag } }, + ) { id } +} + +############################################################################### +# Operations for table: AnyScalarNullableListOfNonNullable +############################################################################### + +mutation AnyScalarNullableListOfNonNullableInsert($tag: String, $value: [Any!]) @auth(level: PUBLIC) { + key: anyScalarNullableListOfNonNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNullableListOfNonNullableInsert3($tag: String, $value1: [Any!], $value2: [Any!], $value3: [Any!]) @auth(level: PUBLIC) { + key1: anyScalarNullableListOfNonNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNullableListOfNonNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNullableListOfNonNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNullableListOfNonNullableGetByKey($key: AnyScalarNullableListOfNonNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNullableListOfNonNullable(key: $key) { value } +} + +query AnyScalarNullableListOfNonNullableGetAllByTagAndValue($tag: String, $value: [Any!]) @auth(level: PUBLIC) { + items: anyScalarNullableListOfNonNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { includesAll: $value }, tag: { eq: $tag } }, + ) { id } +} + +############################################################################### +# Operations for table: AnyScalarNonNullableListOfNullable +############################################################################### + +mutation AnyScalarNonNullableListOfNullableInsert($tag: String, $value: [Any!]!) @auth(level: PUBLIC) { + key: anyScalarNonNullableListOfNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNonNullableListOfNullableInsert3($tag: String, $value1: [Any!]!, $value2: [Any!]!, $value3: [Any!]!) @auth(level: PUBLIC) { + key1: anyScalarNonNullableListOfNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNonNullableListOfNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNonNullableListOfNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNonNullableListOfNullableGetByKey($key: AnyScalarNonNullableListOfNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNonNullableListOfNullable(key: $key) { value } +} + +query AnyScalarNonNullableListOfNullableGetAllByTagAndValue($tag: String, $value: [Any!]!) @auth(level: PUBLIC) { + items: anyScalarNonNullableListOfNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { includesAll: $value }, tag: { eq: $tag } }, + ) { id } +} + +############################################################################### +# Operations for table: AnyScalarNonNullableListOfNonNullable +############################################################################### + +mutation AnyScalarNonNullableListOfNonNullableInsert($tag: String, $value: [Any!]!) @auth(level: PUBLIC) { + key: anyScalarNonNullableListOfNonNullable_insert(data: { tag: $tag, value: $value }) +} + +mutation AnyScalarNonNullableListOfNonNullableInsert3($tag: String, $value1: [Any!]!, $value2: [Any!]!, $value3: [Any!]!) @auth(level: PUBLIC) { + key1: anyScalarNonNullableListOfNonNullable_insert(data: { value: $value1, tag: $tag, position: 1 }) + key2: anyScalarNonNullableListOfNonNullable_insert(data: { value: $value2, tag: $tag, position: 2 }) + key3: anyScalarNonNullableListOfNonNullable_insert(data: { value: $value3, tag: $tag, position: 3 }) +} + +query AnyScalarNonNullableListOfNonNullableGetByKey($key: AnyScalarNonNullableListOfNonNullable_Key!) @auth(level: PUBLIC) { + item: anyScalarNonNullableListOfNonNullable(key: $key) { value } +} + +query AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue($tag: String, $value: [Any!]!) @auth(level: PUBLIC) { + items: anyScalarNonNullableListOfNonNullables( + limit: 5, + orderBy: { position: ASC }, + where: { value: { includesAll: $value }, tag: { eq: $tag } }, + ) { id } +} diff --git a/firebase-dataconnect/emulator/dataconnect/connector/keywords/connector.yaml b/firebase-dataconnect/emulator/dataconnect/connector/keywords/connector.yaml new file mode 100644 index 00000000000..e6bb5f53a58 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/keywords/connector.yaml @@ -0,0 +1,8 @@ +connectorId: keywords +authMode: PUBLIC +generate: + kotlinSdk: + outputDir: ../../.generated/keywords + # Use a Kotlin keyword ("typealias", in this case) in the package name to ensure that it gets + # correctly escaped by codegen. + package: com.google.firebase.dataconnect.connectors.typealias diff --git a/firebase-dataconnect/emulator/dataconnect/connector/keywords/keyword_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/keywords/keyword_ops.gql new file mode 100644 index 00000000000..9b755ba2384 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/keywords/keyword_ops.gql @@ -0,0 +1,55 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# A mutation named using a Kotlin keyword. +mutation do($id: String!, $bar: String) + @auth(level: PUBLIC) { + foo_insert(data: {id: $id, bar: $bar}) +} + +# A query named using a Kotlin keyword. +query return($id: String!) + @auth(level: PUBLIC) { + foo(id: $id) { + bar + } +} + +# A mutation with a variable named using a Kotlin keyword. +mutation DeleteFoo($is: String!) + @auth(level: PUBLIC) { + foo_delete(id: $is) +} + +# A query with a variable named using a Kotlin keyword. +query GetFoosByBar($as: String) + @auth(level: PUBLIC) { + foos(where: {bar: {eq: $as}}) { + id + } +} + +# A mutation with fields in the selection set that are Kotlin keywords. +mutation InsertTwoFoos($id1: String!, $id2: String!, $bar1: String, $bar2: String) + @auth(level: PUBLIC) { + val: foo_insert(data: {id: $id1, bar: $bar1}) + var: foo_insert(data: {id: $id2, bar: $bar2}) +} + +# A query with fields in the selection set that are Kotlin keywords. +query GetTwoFoosById($id1: String!, $id2: String!) + @auth(level: PUBLIC) { + super: foo(id: $id1) { id bar } + this: foo(id: $id2) { id bar } +} diff --git a/firebase-dataconnect/emulator/dataconnect/connector/person/connector.yaml b/firebase-dataconnect/emulator/dataconnect/connector/person/connector.yaml new file mode 100644 index 00000000000..d5de76f0ff9 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/person/connector.yaml @@ -0,0 +1,2 @@ +connectorId: person +authMode: PUBLIC diff --git a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql new file mode 100644 index 00000000000..ffc8281e0fd --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql @@ -0,0 +1,90 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +mutation createPerson($id: String, $name: String!, $age: Int) @auth(level: PUBLIC) { + person_insert(data: { + id: $id, + name: $name, + age: $age + }) +} + +mutation createDefaultPerson @auth(level: PUBLIC) { + person_insert(data: {name: "DefaultName", age: 42}) +} + +mutation deletePerson($id: String!) @auth(level: PUBLIC) { + person_delete(id: $id) +} + +mutation updatePerson($id: String!, $name: String!, $age: Int) @auth(level: PUBLIC) { + person_update(id: $id, data: { + name: $name, + age: $age + }) +} + +query getPerson($id: String!) @auth(level: PUBLIC) { + person(id: $id) { + name + age + } +} + +query getNoPeople @auth(level: PUBLIC) { + people(where: {id: {eq: "Some ID that does not match any rows"}}) { + id + } +} + +query getPeopleByName($name: String!) @auth(level: PUBLIC) { + people(where: {name: {eq: $name}}) { + id + age + } +} + +query getPeopleWithHardcodedName @auth(level: PUBLIC) { + people(where: {name: {eq: "HardcodedName_v1"}}) { + id + age + } +} + +mutation createPeopleWithHardcodedName @auth(level: PUBLIC) { + person1: person_upsert(data: { + id: "HardcodedNamePerson1Id_v1", + name: "HardcodedName_v1" + }) + person2: person_upsert(data: { + id: "HardcodedNamePerson2Id_v1", + name: "HardcodedName_v1", + age: 42 + }) +} + +mutation createPersonAuth($id: String, $name: String!, $age: Int) @auth(level: USER_ANON) { + person_insert(data: { + id: $id, + name: $name, + age: $age + }) +} + +query getPersonAuth($id: String!) @auth(level: USER_ANON) { + person(id: $id) { + name + age + } +} diff --git a/firebase-dataconnect/emulator/dataconnect/connector/posts/connector.yaml b/firebase-dataconnect/emulator/dataconnect/connector/posts/connector.yaml new file mode 100644 index 00000000000..a0b78b6cfdf --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/posts/connector.yaml @@ -0,0 +1,2 @@ +connectorId: posts +authMode: PUBLIC diff --git a/firebase-dataconnect/emulator/dataconnect/connector/posts/posts_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/posts/posts_ops.gql new file mode 100644 index 00000000000..28da86c0940 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/connector/posts/posts_ops.gql @@ -0,0 +1,53 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +query getPost($id: String!) @auth(level: PUBLIC) { + post(id: $id) { + content + comments: comments_on_post { + id + content + } + } +} +query listPosts @auth(level: PUBLIC) { + posts { + id + content + } +} + +query listPostsOnlyId @auth(level: PUBLIC) { + posts { + id + } +} + +mutation createPost($id: String!, $content: String!) @auth(level: PUBLIC) { + post_insert(data: { + id: $id, + content: $content + }) +} +mutation deletePost($id: String!) @auth(level: PUBLIC) { + post_delete(id: $id) +} + +mutation createComment($id: String!, $content: String!, $postId: String!) @auth(level: PUBLIC) { + comment_insert(data: { + id: $id, + content: $content, + postId: $postId + }) +} diff --git a/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml b/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml new file mode 100644 index 00000000000..a17c5213bc0 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/dataconnect.yaml @@ -0,0 +1,17 @@ +specVersion: "v1beta" +serviceId: "sid2ehn9ct8te" +location: "us-central1" +schema: + source: "./schema" + datasource: + postgresql: + database: "dba6g7djscd5" + cloudSql: + instanceId: "iidtpktdqb8gxm" +connectorDirs: [ + "./connector/demo", + "./connector/alltypes", + "./connector/keywords", + "./connector/person", + "./connector/posts", +] diff --git a/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql new file mode 100644 index 00000000000..47465c76f3a --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql @@ -0,0 +1,64 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +type Primitive @table { + id: UUID! + idFieldNullable: UUID + intField: Int! + intFieldNullable: Int + floatField: Float! + floatFieldNullable: Float + booleanField: Boolean! + booleanFieldNullable: Boolean + stringField: String! + stringFieldNullable: String +} + +type PrimitiveList @table { + id: UUID! + idListNullable: [UUID!] + idListOfNullable: [UUID]! + intList: [Int!]! + intListNullable: [Int!] + intListOfNullable: [Int]! + floatList: [Float!]! + floatListNullable: [Float!] + floatListOfNullable: [Float]! + booleanList: [Boolean!]! + booleanListNullable: [Boolean!] + booleanListOfNullable: [Boolean]! + stringList: [String!]! + stringListNullable: [String!] + stringListOfNullable: [String]! +} + +type Farm @table { + id: String! + name: String! + farmer: Farmer! +} + +type Animal @table { + id: String! + farm: Farm! + name: String! + species: String! + age: Int +} + +type Farmer @table { + id: String! + name: String! + parent: Farmer +} diff --git a/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql new file mode 100644 index 00000000000..d497f7726e3 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql @@ -0,0 +1,339 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +type Foo @table { + id: String! + bar: String +} + +type StringVariants @table { + nonNullWithNonEmptyValue: String! + nonNullWithEmptyValue: String! + nullableWithNullValue: String + nullableWithNonNullValue: String + nullableWithEmptyValue: String +} + +type IntVariants @table { + nonNullWithZeroValue: Int! + nonNullWithPositiveValue: Int! + nonNullWithNegativeValue: Int! + nonNullWithMaxValue: Int! + nonNullWithMinValue: Int! + nullableWithNullValue: Int + nullableWithZeroValue: Int + nullableWithPositiveValue: Int + nullableWithNegativeValue: Int + nullableWithMaxValue: Int + nullableWithMinValue: Int +} + +type FloatVariants @table { + nonNullWithZeroValue: Float! + nonNullWithNegativeZeroValue: Float! + nonNullWithPositiveValue: Float! + nonNullWithNegativeValue: Float! + nonNullWithMaxValue: Float! + nonNullWithMinValue: Float! + nonNullWithMaxSafeIntegerValue: Float! + nullableWithNullValue: Float + nullableWithZeroValue: Float + nullableWithNegativeZeroValue: Float + nullableWithPositiveValue: Float + nullableWithNegativeValue: Float + nullableWithMaxValue: Float + nullableWithMinValue: Float + nullableWithMaxSafeIntegerValue: Float +} + +type BooleanVariants @table { + nonNullWithTrueValue: Boolean! + nonNullWithFalseValue: Boolean! + nullableWithNullValue: Boolean + nullableWithTrueValue: Boolean + nullableWithFalseValue: Boolean +} + +type Int64Variants @table { + nonNullWithZeroValue: Int64! + nonNullWithPositiveValue: Int64! + nonNullWithNegativeValue: Int64! + nonNullWithMaxValue: Int64! + nonNullWithMinValue: Int64! + nullableWithNullValue: Int64 + nullableWithZeroValue: Int64 + nullableWithPositiveValue: Int64 + nullableWithNegativeValue: Int64 + nullableWithMaxValue: Int64 + nullableWithMinValue: Int64 +} + +type UUIDVariants @table { + nonNullValue: UUID! + nullableWithNullValue: UUID + nullableWithNonNullValue: UUID +} + +type SyntheticId @table { + value: String! +} + +type PrimaryKeyIsString @table { + id: String! + value: String! +} + +type PrimaryKeyIsInt @table(key: ["foo"]) { + foo: Int! + value: String! +} + +type PrimaryKeyIsFloat @table(key: ["foo"]) { + foo: Float! + value: String! +} + +type PrimaryKeyIsUUID @table { + id: UUID! + value: String! +} + +type PrimaryKeyIsDate @table(key: ["foo"]) { + foo: Date! + value: String! +} + +type PrimaryKeyIsTimestamp @table(key: ["foo"]) { + foo: Timestamp! + value: String! +} + +type PrimaryKeyIsInt64 @table(key: ["foo"]) { + foo: Int64! + value: String! +} + +type PrimaryKeyIsComposite @table(key: ["foo", "bar", "baz"]) { + foo: Int! + bar: String! + baz: Boolean! + value: String! +} + +type PrimaryKeyNested1 @table { + value: String! +} + +type PrimaryKeyNested2 @table { + value: String! +} + +type PrimaryKeyNested3 @table { + value: String! +} + +type PrimaryKeyNested4 @table { + value: String! +} + +type PrimaryKeyNested5 @table(key: ["nested1", "nested2"]) { + value: String! + nested1: PrimaryKeyNested1! @ref(constraintName: "xc78y5zy8g") + nested2: PrimaryKeyNested2! @ref(constraintName: "zhc54nhp9y") +} + +type PrimaryKeyNested6 @table(key: ["nested3", "nested4"]) { + value: String! + nested3: PrimaryKeyNested3! @ref(constraintName: "ecwmmfw7mc") + nested4: PrimaryKeyNested4! @ref(constraintName: "kj6rf4krsy") +} + +type PrimaryKeyNested7 @table(key: ["nested5a", "nested5b", "nested6"]) { + value: String! + nested5a: PrimaryKeyNested5! @ref(constraintName: "scp8jctndd") + nested5b: PrimaryKeyNested5! @ref(constraintName: "vs8pak27zd") + nested6: PrimaryKeyNested6! @ref(constraintName: "sgnjjj4j6z") +} + +type Nested1 @table { + value: String! + nested1: Nested1 @ref(constraintName: "d7ehkzccaf") + nested2: Nested2! @ref(constraintName: "3xzv2rnqvx") + nested2NullableNull: Nested2 @ref(constraintName: "6ey7mpzmja") + nested2NullableNonNull: Nested2! @ref(constraintName: "fy65d2frd4") +} + +type Nested2 @table { + value: String! + nested3: Nested3!@ref(constraintName: "wf72fzcndy") + nested3NullableNull: Nested3 @ref(constraintName: "btzepr3n67") + nested3NullableNonNull: Nested3! @ref(constraintName: "tsse8qpwpq") +} + +type Nested3 @table { + value: String! +} + +type ManyToOneParent @table { + child: ManyToOneChild @ref(constraintName: "y9pbzvyeb5") +} + +type ManyToOneChild @table { + value: String +} + +type ManyToManyChildA @table { + value: String +} + +type ManyToManyChildB @table { + value: String +} + +type ManyToManyParent @table(key: ["childA", "childB"]) { + childA: ManyToManyChildA! @ref(constraintName: "kneaq52b9z") + childB: ManyToManyChildB! @ref(constraintName: "pj3hs9yrv2") +} + +type ManyToOneSelfCustomName @table { + ref: ManyToOneSelfCustomName @ref(constraintName: "aetgz9hzcg") +} + +type ManyToOneSelfMatchingName @table { + manyToOneSelfMatchingName: ManyToOneSelfMatchingName @ref(constraintName: "qq6gzw5dfk") +} + +type ManyToManySelfParent @table(key: ["child1", "child2"]) { + child1: ManyToManySelfChild! @ref(constraintName: "k2v4gjr95k") + child2: ManyToManySelfChild! @ref(constraintName: "tew95zy8m8") +} + +type ManyToManySelfChild @table { + value: String +} + +type OptionalStrings @table { + required1: String! + required2: String! + nullable1: String + nullable2: String + nullable3: String + nullableWithSchemaDefault: String @default(value: "pb429m") +} + +type NonNullableLists @table { + strings: [String!]! + ints: [Int!]! + floats: [Float!]! + booleans: [Boolean!]! + uuids: [UUID!]! + int64s: [Int64!]! + dates: [Date!]! + timestamps: [Timestamp!]! +} + +type NullableLists @table { + strings: [String!] + ints: [Int!] + floats: [Float!] + booleans: [Boolean!] + uuids: [UUID!] + int64s: [Int64!] + dates: [Date!] + timestamps: [Timestamp!] +} + +type NonNullDate @table { + value: Date! +} + +type NullableDate @table { + value: Date +} + +type NonNullDatesWithDefaults @table { + valueWithVariableDefault: Date! + valueWithSchemaDefault: Date! @default(value: "2112-01-31") + epoch: Date! @default(sql: "'epoch'::date") + requestTime1: Date! @default(expr: "request.time") + requestTime2: Date! @default(expr: "request.time") +} + +type NullableDatesWithDefaults @table { + valueWithVariableDefault: Date + valueWithSchemaDefault: Date @default(value: "1921-12-02") + epoch: Date @default(sql: "'epoch'::date") + requestTime1: Date @default(expr: "request.time") + requestTime2: Date @default(expr: "request.time") +} + +type NonNullTimestamp @table { + value: Timestamp! +} + +type NullableTimestamp @table { + value: Timestamp +} + +type NonNullTimestampsWithDefaults @table { + valueWithVariableDefault: Timestamp! + valueWithSchemaDefault: Timestamp! @default(value: "6224-01-31T14:02:45.714214Z") + epoch: Timestamp! @default(sql: "'epoch'::timestamptz") + requestTime1: Timestamp! @default(expr: "request.time") + requestTime2: Timestamp! @default(expr: "request.time") +} + +type NullableTimestampsWithDefaults @table { + valueWithVariableDefault: Timestamp + valueWithSchemaDefault: Timestamp @default(value: "1621-12-03T01:22:03.513914Z") + epoch: Timestamp @default(sql: "'epoch'::timestamptz") + requestTime1: Timestamp @default(expr: "request.time") + requestTime2: Timestamp @default(expr: "request.time") +} + +type AnyScalarNonNullable @table @index(fields: ["tag"]) { + value: Any! + tag: String + position: Int +} + +type AnyScalarNullable @table @index(fields: ["tag"]) { + value: Any + tag: String + position: Int +} + +type AnyScalarNullableListOfNullable @table @index(fields: ["tag"]) { + value: [Any] + tag: String + position: Int +} + +type AnyScalarNullableListOfNonNullable @table @index(fields: ["tag"]) { + value: [Any!] + tag: String + position: Int +} + +type AnyScalarNonNullableListOfNullable @table @index(fields: ["tag"]) { + value: [Any]! + tag: String + position: Int +} + +type AnyScalarNonNullableListOfNonNullable @table @index(fields: ["tag"]) { + value: [Any!]! + tag: String + position: Int +} diff --git a/firebase-dataconnect/emulator/dataconnect/schema/person_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/person_schema.gql new file mode 100644 index 00000000000..24babe580d2 --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/schema/person_schema.gql @@ -0,0 +1,19 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +type Person @table { + id: String! @default(expr: "uuidV4()") + name: String! + age: Int +} diff --git a/firebase-dataconnect/emulator/dataconnect/schema/posts_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/posts_schema.gql new file mode 100644 index 00000000000..9f378f264ae --- /dev/null +++ b/firebase-dataconnect/emulator/dataconnect/schema/posts_schema.gql @@ -0,0 +1,24 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +type Post @table { + id: String! + content: String! +} + +type Comment @table { + id: String! + content: String! + post: Post! +} diff --git a/firebase-dataconnect/emulator/emulator.sh b/firebase-dataconnect/emulator/emulator.sh new file mode 100755 index 00000000000..68ccf3331a7 --- /dev/null +++ b/firebase-dataconnect/emulator/emulator.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +echo "[$0] PID=$$" + +readonly SELF_DIR="$(dirname "$0")" + +readonly FIREBASE_ARGS=( + firebase + --debug + emulators:start + --only auth,dataconnect +) + +echo "[$0] Running command: ${FIREBASE_ARGS[*]}" +exec "${FIREBASE_ARGS[@]}" diff --git a/firebase-dataconnect/emulator/firebase.json b/firebase-dataconnect/emulator/firebase.json new file mode 100644 index 00000000000..d6ccfcb85f3 --- /dev/null +++ b/firebase-dataconnect/emulator/firebase.json @@ -0,0 +1,17 @@ +{ + "dataconnect": { + "source": "dataconnect" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true, + "dataconnect": { + "port": 9399 + } + } +} diff --git a/firebase-dataconnect/emulator/servers.json b/firebase-dataconnect/emulator/servers.json new file mode 100644 index 00000000000..a4677676d2a --- /dev/null +++ b/firebase-dataconnect/emulator/servers.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "localhost", + "Group": "Servers", + "Host": "localhost", + "Port": 5432, + "MaintenanceDB": "postgres", + "Username": "postgres", + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0, + "KerberosAuthentication": false, + "ConnectionParameters": { + "sslmode": "prefer", + "connect_timeout": 10, + "sslcert": "/.postgresql/postgresql.crt", + "sslkey": "/.postgresql/postgresql.key" + } + } + } +} \ No newline at end of file diff --git a/firebase-dataconnect/emulator/start_postgres_pod.sh b/firebase-dataconnect/emulator/start_postgres_pod.sh new file mode 100755 index 00000000000..f0731967c26 --- /dev/null +++ b/firebase-dataconnect/emulator/start_postgres_pod.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This script starts a postgresql server and pgadmin web interface using +# a docker image and the "podman" command. It is safe to run this script if the +# server is already running (it will just be a no-op). + +set -euo pipefail +set -xv + +# Determine the absolute path of the directory containing this file. +readonly SCRIPT_DIR="$(readlink -f $(dirname "$0"))" + +# Create the podman "pod" if it is not already created. +# Bind the PostgreSQL server to port 5432 on the host, so that the host can connect to it. +# Bind the pgadmin server to port 8888 on the host, so that the host can connect to it. +if ! podman pod exists dataconnect_postgres ; then + podman pod create -p 5432:5432 -p 8888:80 dataconnect_postgres +fi + +# Start the PostgreSQL server. +podman \ + run \ + -dt \ + --rm \ + --pod dataconnect_postgres \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + --mount "type=volume,src=dataconnect_pgdata,dst=/var/lib/postgresql/data" \ + docker.io/library/postgres:15 + +# Start the pgadmin4 server. +readonly PGADMIN_EMAIL="admin@google.com" +readonly PGADMIN_PASSWORD="password" +podman \ + run \ + -dt \ + --rm \ + --pod dataconnect_postgres \ + -e PGADMIN_DEFAULT_EMAIL="${PGADMIN_EMAIL}" \ + -e PGADMIN_DEFAULT_PASSWORD="${PGADMIN_PASSWORD}" \ + --mount "type=bind,ro,src=${SCRIPT_DIR}/servers.json,dst=/pgadmin4/servers.json" \ + --mount "type=volume,src=dataconnect_pgadmin_data,dst=/var/lib/pgadmin" \ + docker.io/dpage/pgadmin4 + +# Turn off verbose logging so that the epilogue below is not littered with bash statements. +set +xv +echo + +cat < + task.builtins { + create("kotlin") { + option("lite") + } + } + task.plugins { + create("java") { + option("lite") + } + create("grpc") { + option("lite") + } + create("grpckt") { + option("lite") + } + } + } + } +} + +dependencies { + api("com.google.firebase:firebase-common:21.0.0") + + implementation("com.google.firebase:firebase-annotations:16.2.0") + implementation("com.google.firebase:firebase-appcheck-interop:17.1.0") + implementation("com.google.firebase:firebase-auth-interop:20.0.0") + implementation("com.google.firebase:firebase-components:18.0.0") + + compileOnly(libs.javax.annotation.jsr250) + implementation(libs.grpc.android) + implementation(libs.grpc.kotlin.stub) + implementation(libs.grpc.okhttp) + implementation(libs.grpc.protobuf.lite) + implementation(libs.grpc.stub) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.protobuf.java.lite) + implementation(libs.protobuf.kotlin.lite) + + testCompileOnly(libs.protobuf.java) + testImplementation(project(":firebase-dataconnect:testutil")) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.kotest.assertions) + testImplementation(libs.kotest.property) + testImplementation(libs.kotest.property.arbs) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.kotlinx.serialization.json) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.truth) + testImplementation(libs.truth.liteproto.extension) + + androidTestImplementation(project(":firebase-dataconnect:androidTestutil")) + androidTestImplementation(project(":firebase-dataconnect:connectors")) + androidTestImplementation(project(":firebase-dataconnect:testutil")) + androidTestImplementation("com.google.firebase:firebase-appcheck:18.0.0") + androidTestImplementation("com.google.firebase:firebase-auth:22.3.1") + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotest.assertions) + androidTestImplementation(libs.kotest.property) + androidTestImplementation(libs.kotest.property.arbs) + androidTestImplementation(libs.kotlin.coroutines.test) + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.truth.liteproto.extension) + androidTestImplementation(libs.turbine) +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} + +// Enable Kotlin "Explicit API Mode". This causes the Kotlin compiler to fail if any +// classes, methods, or properties have implicit `public` visibility. This check helps +// avoid accidentally leaking elements into the public API, requiring that any public +// element be explicitly declared as `public`. +// https://github.com/Kotlin/KEEP/blob/master/proposals/explicit-api-mode.md +// https://chao2zhang.medium.com/explicit-api-mode-for-kotlin-on-android-b8264fdd76d1 +tasks.withType().all { + if (!name.contains("test", ignoreCase = true)) { + if (!kotlinOptions.freeCompilerArgs.contains("-Xexplicit-api=strict")) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } + } +} diff --git a/firebase-dataconnect/google-services.json b/firebase-dataconnect/google-services.json new file mode 100644 index 00000000000..45de107a9c6 --- /dev/null +++ b/firebase-dataconnect/google-services.json @@ -0,0 +1,24 @@ +{ + "project_info": { + "project_number": "12345678901", + "firebase_url": "https://prjh5zbv64sv6.firebaseio.com", + "project_id": "prjh5zbv64sv6", + "storage_bucket": "prjh5zbv64sv6.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:12345678901:android:1234567890abcdef123456", + "android_client_info": { + "package_name": "com.google.firebase.dataconnect" + } + }, + "api_key": [ + { + "current_key": "AIzayDNSXIbFmlXbIE6mCzDLQAqITYefhixbX4A" + } + ] + } + ], + "configuration_version": "1" +} diff --git a/firebase-dataconnect/gradle.properties b/firebase-dataconnect/gradle.properties new file mode 100644 index 00000000000..b344400aed0 --- /dev/null +++ b/firebase-dataconnect/gradle.properties @@ -0,0 +1,2 @@ +version=16.0.0-beta01 +latestReleasedVersion=16.0.0-alpha05 diff --git a/firebase-dataconnect/gradleplugin/gradle.properties b/firebase-dataconnect/gradleplugin/gradle.properties new file mode 100644 index 00000000000..3dcf88f023c --- /dev/null +++ b/firebase-dataconnect/gradleplugin/gradle.properties @@ -0,0 +1,2 @@ +# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 +org.gradle.parallel=true diff --git a/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml b/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml new file mode 100644 index 00000000000..d3172260426 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +[versions] +androidGradlePlugin = "8.2.1" +kotlin = "1.8.22" + +[libraries] +android-gradlePlugin-api = { group = "com.android.tools.build", name = "gradle-api", version.ref = "androidGradlePlugin" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version = "7.0.0.BETA1" } diff --git a/firebase-dataconnect/gradleplugin/gradle/wrapper/gradle-wrapper.properties b/firebase-dataconnect/gradleplugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..e6aba2515d5 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts new file mode 100644 index 00000000000..95b578dabd3 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + `java-gradle-plugin` + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.spotless) +} + +java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } + +dependencies { + compileOnly(libs.android.gradlePlugin.api) + implementation(gradleKotlinDsl()) +} + +gradlePlugin { + plugins { + create("dataconnect") { + id = "com.google.firebase.dataconnect.gradle.plugin" + implementationClass = "com.google.firebase.dataconnect.gradle.plugin.DataConnectGradlePlugin" + } + } +} + +spotless { + kotlin { ktfmt("0.41").googleStyle() } + kotlinGradle { + target("*.gradle.kts") + ktfmt("0.41").googleStyle() + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/java/com/google/firebase/dataconnect/gradle/plugin/TransformerInterop.java b/firebase-dataconnect/gradleplugin/plugin/src/main/java/com/google/firebase/dataconnect/gradle/plugin/TransformerInterop.java new file mode 100644 index 00000000000..5edfdada0cd --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/java/com/google/firebase/dataconnect/gradle/plugin/TransformerInterop.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin; + +import org.gradle.api.Transformer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// TODO: Remove this interface and use Transformer directly once the Kotlin version is upgraded to +// a later version that doesn't require it, such as 1.9.25. Using this interface works around the +// following Kotlin compiler error: +// +// > Task :plugin:compileKotlin FAILED +// e: DataConnectGradlePlugin.kt:93:15 Type mismatch: inferred type is RegularFile? but TypeVariable(S) was expected +// e: DataConnectGradlePlugin.kt:102:15 Type mismatch: inferred type is String? but TypeVariable(S) was expected +// e: DataConnectGradlePlugin.kt:111:15 Type mismatch: inferred type is DataConnectExecutable.VerificationInfo? but TypeVariable(S) was expected +public interface TransformerInterop extends Transformer { + + @Override + @Nullable OUT transform(@NotNull IN in); + +} \ No newline at end of file diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/AgpKotlinExtensions.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/AgpKotlinExtensions.kt new file mode 100644 index 00000000000..e1ea90a9390 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/AgpKotlinExtensions.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UnstableApiUsage") + +package com.google.firebase.dataconnect.gradle.plugin + +import com.android.build.api.variant.Variant +import com.android.build.api.variant.VariantExtensionConfig + +inline fun Variant.getExtension(): T = + getExtensionOrNull() + ?: throw IllegalStateException( + "no extension ${T::class.qualifiedName} registered with variant $name" + ) + +inline fun Variant.getExtensionOrNull(): T? = getExtension(T::class.java) + +inline fun VariantExtensionConfig<*>.buildTypeExtension(): T = + buildTypeExtension(T::class.java) + +inline fun VariantExtensionConfig<*>.productFlavorsExtensions(): List = + productFlavorsExtensions(T::class.java) diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectDslExtension.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectDslExtension.kt new file mode 100644 index 00000000000..8042da476ec --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectDslExtension.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.File +import javax.inject.Inject +import org.gradle.api.file.RegularFile +import org.gradle.api.model.ObjectFactory +import org.gradle.kotlin.dsl.newInstance + +abstract class DataConnectDslExtension @Inject constructor(objectFactory: ObjectFactory) { + + /** The directory containing `dataconnect.yaml` to use, instead of the default directories. */ + abstract var configDir: File? + + /** The Data Connect executable to use. */ + abstract var dataConnectExecutable: DataConnectExecutable? + + /** Convenience DSL for configuring [dataConnectExecutable]. */ + fun dataConnectExecutable(block: DataConnectExecutableBuilder.() -> Unit) { + dataConnectExecutable = + DataConnectExecutableBuilderImpl(dataConnectExecutable).apply(block).build() + } + + /** + * Values to use when performing code generation, which override the values from those defined in + * the outer scope. + */ + val codegen: DataConnectCodegenDslExtension = + objectFactory.newInstance() + + /** + * Configure values to use when performing code generation, which override the values from those + * defined in the outer scope. + */ + @Suppress("unused") + fun codegen(block: DataConnectCodegenDslExtension.() -> Unit): Unit = block(codegen) + + /** + * Values to use when running the Data Connect emulator, which override the values from those + * defined in the outer scope. + */ + val emulator: DataConnectEmulatorDslExtension = + objectFactory.newInstance() + + /** + * Configure values to use when running the Data Connect emulator, which override the values from + * those defined in the outer scope. + */ + @Suppress("unused") + fun emulator(block: DataConnectEmulatorDslExtension.() -> Unit): Unit = block(emulator) + + /** + * Values to use when performing code generation, which override the values from those defined in + * the outer scope. + */ + abstract class DataConnectCodegenDslExtension { + /** + * The IDs of connectors defined by `dataconnect.yaml` for which to generate code. + * + * If `null` or an empty list, then generate code for _all_ connectors. + */ + abstract var connectors: Collection? + } + + /** + * Values to use when running the Data Connect emulator, which override the values from those + * defined in the outer scope. + */ + abstract class DataConnectEmulatorDslExtension { + abstract var postgresConnectionUrl: String? + abstract var schemaExtensionsOutputEnabled: Boolean? + } + + interface DataConnectExecutableBuilder { + var version: String? + var file: File? + var regularFile: RegularFile? + var fileSizeInBytes: Long? + var sha512DigestHex: String? + var verificationEnabled: Boolean + } + + private class DataConnectExecutableBuilderImpl(initialValues: DataConnectExecutable?) : + DataConnectExecutableBuilder { + private var _version: String? = null + override var version: String? + get() = _version + set(value) { + _version = value + _file = null + _regularFile = null + } + private var _file: File? = null + override var file: File? + get() = _file + set(value) { + _version = null + _file = value + _regularFile = null + } + private var _regularFile: RegularFile? = null + override var regularFile: RegularFile? + get() = _regularFile + set(value) { + _version = null + _file = null + _regularFile = value + } + + override var fileSizeInBytes: Long? = null + override var sha512DigestHex: String? = null + override var verificationEnabled: Boolean = true + + fun updateFrom(info: DataConnectExecutable.File) { + file = info.file + updateFrom(info.verificationInfo) + } + + fun updateFrom(info: DataConnectExecutable.RegularFile) { + regularFile = info.file + updateFrom(info.verificationInfo) + } + + fun updateFrom(info: DataConnectExecutable.Version) { + version = info.version + updateFrom(info.verificationInfo) + } + + fun updateFrom(info: DataConnectExecutable.VerificationInfo?) { + verificationEnabled = info !== null + fileSizeInBytes = info?.fileSizeInBytes + sha512DigestHex = info?.sha512DigestHex + } + + init { + when (initialValues) { + is DataConnectExecutable.File -> updateFrom(initialValues) + is DataConnectExecutable.RegularFile -> updateFrom(initialValues) + is DataConnectExecutable.Version -> updateFrom(initialValues) + null -> {} + } + } + + fun build(): DataConnectExecutable? { + val version = version + val file = file + val regularFile = regularFile + val fileSizeInBytes = fileSizeInBytes + val sha512DigestHex = sha512DigestHex + val verificationEnabled = verificationEnabled + + if (version === null && file === null && regularFile === null) { + return null + } else if (version !== null && file !== null && regularFile !== null) { + throw DataConnectGradleException( + "vhtb9jjz87", + "All of 'version', 'file', and 'regularFile' are set," + + " but at most *one* of them may be set" + + " (version=$version, file=$file, regularFile=$regularFile)" + ) + } else if (version !== null && file !== null) { + throw DataConnectGradleException( + "fj95rq5t8k", + "Both 'version' and 'file' are set," + + " but at most *one* of 'version', 'file', and 'regularFile' may be set" + + " (version=$version, file=$file, regularFile=$regularFile)" + ) + } else if (version !== null && regularFile !== null) { + throw DataConnectGradleException( + "ye6abzj5jz", + "Both 'version' and 'regularFile' are set," + + " but at most *one* of 'version', 'file', and 'regularFile' may be set" + + " (version=$version, file=$file, regularFile=$regularFile)" + ) + } else if (file !== null && regularFile !== null) { + throw DataConnectGradleException( + "nw79x53zdq", + "Both 'file' and 'regularFile' are set," + + " but at most *one* of 'version', 'file', and 'regularFile' may be set" + + " (version=$version, file=$file, regularFile=$regularFile)" + ) + } + + val verificationInfo: DataConnectExecutable.VerificationInfo? = + if (!verificationEnabled) { + null + } else if (fileSizeInBytes === null && sha512DigestHex === null) { + if (version !== null) { + DataConnectExecutable.VerificationInfo.forVersion(version) + } else { + throw DataConnectGradleException( + "8s9venv4ch", + "Both 'fileSizeInBytes' and 'sha512DigestHex' were null" + + " but _both_ must be set when verificationEnabled==true" + + " and file!=null or regularFile!=null" + + " (file=$file regularFile=$regularFile)" + ) + } + } else if (fileSizeInBytes === null || sha512DigestHex === null) { + throw DataConnectGradleException( + "gjzykv9pqq", + "Both 'fileSizeInBytes' and 'sha512DigestHex' have to be set or both unset" + + " when verificationEnabled==true, but one of them was set and the other was not" + + " (fileSizeInBytes=$fileSizeInBytes, sha512DigestHex=$sha512DigestHex)" + ) + } else { + DataConnectExecutable.VerificationInfo( + fileSizeInBytes = fileSizeInBytes, + sha512DigestHex = sha512DigestHex, + ) + } + + return if (version !== null) { + DataConnectExecutable.Version(version = version, verificationInfo = verificationInfo) + } else if (file !== null) { + DataConnectExecutable.File(file = file, verificationInfo = verificationInfo) + } else if (regularFile !== null) { + DataConnectExecutable.RegularFile(file = regularFile, verificationInfo = verificationInfo) + } else { + throw DataConnectGradleException( + "yg49q5nzxt", + "INTERNAL ERROR: version===null && file===null && regularFile===null" + ) + } + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt new file mode 100644 index 00000000000..b8730c0f8e6 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.Serializable + +// The following command was used to generate the `serialVersionUID` constants for each class. +// serialver -classpath \ +// plugin/build/classes/kotlin/main:$(find $HOME/.gradle/wrapper/dists -name +// gradle-core-api-8.5.jar -printf '%p:') \ +// com.google.firebase.dataconnect.gradle.plugin.DataConnectExecutableInput\${VerificationInfo,File,RegularFile,Version} + +sealed interface DataConnectExecutable { + + data class VerificationInfo(val fileSizeInBytes: Long, val sha512DigestHex: String) : + Serializable { + + companion object { + fun forVersion(version: String): VerificationInfo = + when (version) { + "1.3.4" -> + VerificationInfo( + fileSizeInBytes = 24_125_592L, + sha512DigestHex = + "3ec9317db593ebeacfea9756cdd08a02849296fbab67f32f3d811a766be6ce2506f" + + "c7a0cf5f5ea880926f0c4defa5ded965268f5dfe5d07eb80cef926f216c7e" + ) + "1.3.5" -> + VerificationInfo( + fileSizeInBytes = 24_146_072L, + sha512DigestHex = + "630391e3c50568cca36e562e51b300e673fa7190c0cae0475a03e4af4003babe711" + + "98c5b0309ecd261b3a3362e8c4d49bdb6cbc6f2b2d3297444112a018a0c10" + ) + "1.3.6" -> + VerificationInfo( + fileSizeInBytes = 24_785_048L, + sha512DigestHex = + "77b2fd79a8a70e47defb1592a092c63642fda6c33715f1977d7a44daed3d7e181c3" + + "870aad0fee7b035aabea7778a244135ab3e633247ccd5f937105f6d495a26" + ) + "1.3.7" -> + VerificationInfo( + fileSizeInBytes = 24_928_408L, + sha512DigestHex = + "99d9774f3b29a6845f0e096893d1205e69b6f8654797a3fc7d54d22e8f7059d1b65" + + "49ae23b8e8f18c952c1c7d25a07b0b8b29a957abd97e1a79c703448497cef" + ) + "1.3.8" -> + VerificationInfo( + fileSizeInBytes = 24_940_696L, + sha512DigestHex = + "aea3583ebe1a36938eec5164de79405951ddf05b70a857ddb4f346f1424666f1d96" + + "989a5f81326c7e2aef4a195d31ff356fdf2331ed98fa1048c4bd469cbfd97" + ) + else -> + throw DataConnectGradleException( + "3svd27ch8y", + "File size and SHA512 digest is not known for version: $version" + ) + } + } + } + + data class File(val file: java.io.File, val verificationInfo: VerificationInfo?) : + DataConnectExecutable + + data class RegularFile( + val file: org.gradle.api.file.RegularFile, + val verificationInfo: VerificationInfo? + ) : DataConnectExecutable + + data class Version(val version: String, val verificationInfo: VerificationInfo?) : + DataConnectExecutable { + companion object { + fun forVersionWithDefaultVerificationInfo(version: String): Version { + val verificationInfo = DataConnectExecutable.VerificationInfo.forVersion(version) + return Version(version, verificationInfo) + } + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt new file mode 100644 index 00000000000..95249d9d645 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import com.google.firebase.dataconnect.gradle.plugin.DataConnectExecutable.VerificationInfo +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.regex.Pattern +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +abstract class DataConnectExecutableDownloadTask : DefaultTask() { + + @get:InputFile @get:Optional abstract val inputFile: RegularFileProperty + + @get:Input @get:Optional abstract val version: Property + + @get:Input @get:Optional abstract val verificationInfo: Property + + @get:Internal abstract val buildDirectory: DirectoryProperty + + @get:OutputFile abstract val outputFile: RegularFileProperty + + @TaskAction + fun run() { + val inputFile: File? = inputFile.orNull?.asFile + val version: String? = version.orNull + val verificationInfo: VerificationInfo? = verificationInfo.orNull + val buildDirectory: File = buildDirectory.get().asFile + val outputFile: File = outputFile.get().asFile + + logger.info("inputFile: {}", inputFile) + logger.info("version: {}", version) + logger.info("verificationInfo: {}", verificationInfo) + logger.info("buildDirectory: {}", buildDirectory) + logger.info("outputFile: {}", outputFile) + + logger.info("Deleting build directory: {}", buildDirectory) + project.delete(buildDirectory) + + if (inputFile !== null && version !== null) { + throw DataConnectGradleException( + "5t7wvatbr7", + "Both 'inputFile' and 'version' were specified," + + " but exactly _one_ of them is required to be specified" + + " (inputFile=$inputFile version=$version)" + ) + } else if (inputFile !== null) { + runWithFile(inputFile = inputFile, outputFile = outputFile) + } else if (version !== null) { + runWithVersion(version = version, outputFile = outputFile) + } else { + throw DataConnectGradleException( + "chc94cq7vx", + "Neither 'inputFile' nor 'version' were specified," + + " but exactly _one_ of them is required to be specified" + ) + } + + if (verificationInfo !== null) { + verifyOutputFile(outputFile, verificationInfo) + } + } + + private fun verifyOutputFile(outputFile: File, verificationInfo: VerificationInfo) { + logger.info("Verifying file size and SHA512 digest of file: {}", outputFile) + val fileInfo = FileInfo.forFile(outputFile) + if (fileInfo.sizeInBytes != verificationInfo.fileSizeInBytes) { + throw DataConnectGradleException( + "zjdpbsjv42", + "File $outputFile has an unexpected size (in bytes): actual=" + + fileInfo.sizeInBytes.toStringWithThousandsSeparator() + + " expected=" + + verificationInfo.fileSizeInBytes.toStringWithThousandsSeparator() + ) + } else if (fileInfo.sha512DigestHex != verificationInfo.sha512DigestHex) { + throw DataConnectGradleException( + "3yyma4dqga", + "File $outputFile has an unexpected SHA512 digest:" + + " actual=${fileInfo.sha512DigestHex} expected=${verificationInfo.sha512DigestHex}" + ) + } + } + + data class FileInfo(val sizeInBytes: Long, val sha512DigestHex: String) { + companion object { + fun forFile(file: File): FileInfo { + val digest: MessageDigest = MessageDigest.getInstance("SHA-512") + val buffer = ByteArray(8192) + var bytesRead: Long = 0 + + file.inputStream().use { + while (true) { + val curBytesRead = it.read(buffer) + if (curBytesRead < 0) { + break + } + bytesRead += curBytesRead + digest.update(buffer, 0, curBytesRead) + } + } + + return FileInfo(bytesRead, toHexString(digest.digest())) + } + } + } + + private fun runWithFile(inputFile: File, outputFile: File) { + if (inputFile == outputFile) { + logger.info("inputFile == outputFile; nothing to copy ({})", inputFile) + return + } + + logger.info("Copying {} to {}", inputFile, outputFile) + project.copy { + it.from(inputFile) + it.into(outputFile.parentFile) + it.rename(Pattern.quote(inputFile.name), Pattern.quote(outputFile.name)) + } + } + + private fun runWithVersion(version: String, outputFile: File) { + val fileName = "dataconnect-emulator-linux-v$version" + val url = URL("https://storage.googleapis.com/firemat-preview-drop/emulator/$fileName") + + logger.info("Downloading {} to {}", url, outputFile) + project.mkdir(outputFile.parentFile) + + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw DataConnectGradleException( + "n3mj6ahxwt", + "Downloading Data Connect executable from $url failed with HTTP response code" + + " $responseCode: ${connection.responseMessage}" + + " (expected HTTP response code ${HttpURLConnection.HTTP_OK})" + ) + } + + val startTime = System.nanoTime() + val debouncer = Debouncer(5.seconds) + outputFile.outputStream().use { oStream -> + var downloadByteCount: Long = 0 + fun logDownloadedBytes() { + val elapsedTime = (System.nanoTime() - startTime).toDuration(DurationUnit.NANOSECONDS) + logger.info( + "Downloaded {} bytes in {}", + downloadByteCount.toStringWithThousandsSeparator(), + elapsedTime + ) + } + connection.inputStream.use { iStream -> + val buffer = ByteArray(8192) + while (true) { + val readCount = iStream.read(buffer) + if (readCount < 0) { + break + } + downloadByteCount += readCount + debouncer.maybeRun(::logDownloadedBytes) + oStream.write(buffer, 0, readCount) + } + } + logDownloadedBytes() + } + + project.exec { execSpec -> + execSpec.run { + executable = "chmod" + args = listOf("a+x", outputFile.absolutePath) + } + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableLauncher.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableLauncher.kt new file mode 100644 index 00000000000..0e4fb24822b --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableLauncher.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.File +import org.gradle.api.Task + +interface DataConnectExecutableConfig { + var outputDirectory: File? + var connectors: Collection + var listen: String? + var localConnectionString: String? + var logFile: File? + var schemaExtensionsOutputEnabled: Boolean? +} + +fun Task.runDataConnectExecutable( + dataConnectExecutable: File, + subCommand: List, + configDirectory: File, + configure: DataConnectExecutableConfig.() -> Unit, +) { + val config = + object : DataConnectExecutableConfig { + override var outputDirectory: File? = null + override var connectors: Collection = emptyList() + override var listen: String? = null + override var localConnectionString: String? = null + override var logFile: File? = null + override var schemaExtensionsOutputEnabled: Boolean? = null + } + .apply(configure) + + val logFile = config.logFile?.also { project.mkdir(it.parentFile) } + val logFileStream = logFile?.outputStream() + + try { + project.exec { execSpec -> + execSpec.run { + executable(dataConnectExecutable) + isIgnoreExitValue = false + + if (logger.isDebugEnabled) { + args("-v").args("9") + args("-logtostderr") + } else if (logger.isInfoEnabled) { + args("-v").args("2") + args("-logtostderr") + } else if (logFileStream !== null) { + args("-v").args("2") + args("-logtostderr") + standardOutput = logFileStream + errorOutput = logFileStream + } + + args(subCommand) + + args("-config_dir=$configDirectory") + + config.outputDirectory?.let { args("-output_dir=${it.path}") } + config.connectors.let { + if (it.isNotEmpty()) { + args("-connectors=${it.joinToString(",")}") + } + } + config.listen?.let { args("-listen=${it}") } + config.localConnectionString?.let { args("-local_connection_string=${it}") } + config.schemaExtensionsOutputEnabled?.let { args("-enable_output_schema_extensions=${it}") } + } + } + } catch (e: Exception) { + logFileStream?.close() + logFile?.forEachLine { logger.error(it.trimEnd()) } + throw e + } finally { + logFileStream?.close() + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGenerateCodeTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGenerateCodeTask.kt new file mode 100644 index 00000000000..676a3af286f --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGenerateCodeTask.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.File +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +abstract class DataConnectGenerateCodeTask : DefaultTask() { + + @get:InputFile abstract val dataConnectExecutable: RegularFileProperty + + @get:Optional @get:InputFiles abstract val configDirectory: DirectoryProperty + + @get:Input abstract val connectors: Property> + + @get:Internal abstract val buildDirectory: DirectoryProperty + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun run() { + val dataConnectExecutable: File = dataConnectExecutable.get().asFile + val configDirectory: File? = configDirectory.orNull?.asFile + val connectors: Collection = connectors.get().distinct().sorted() + val buildDirectory: File = buildDirectory.get().asFile + val outputDirectory: File = outputDirectory.get().asFile + + logger.info("dataConnectExecutable={}", dataConnectExecutable.absolutePath) + logger.info("configDirectory={}", configDirectory?.absolutePath) + logger.info("connectors={}", connectors.joinToString(", ")) + logger.info("buildDirectory={}", buildDirectory.absolutePath) + logger.info("outputDirectory={}", outputDirectory.absolutePath) + + if (outputDirectory.exists()) { + logger.info("Deleting directory: $outputDirectory") + project.delete(outputDirectory) + } + + if (configDirectory === null) { + logger.info("No Data Connect config directories found; nothing to do") + return + } + + runDataConnectExecutable( + dataConnectExecutable = dataConnectExecutable, + subCommand = listOf("gradle", "generate"), + configDirectory = configDirectory, + ) { + this.connectors = connectors + this.outputDirectory = outputDirectory + this.logFile = File(buildDirectory, "log.txt") + } + + logger.info("Completed successfully") + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradleException.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradleException.kt new file mode 100644 index 00000000000..904d79a85bd --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradleException.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import org.gradle.api.GradleException + +class DataConnectGradleException(errorCode: String, message: String, cause: Throwable? = null) : + GradleException("$message (error code: $errorCode)", cause) diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt new file mode 100644 index 00000000000..02a10b10032 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("UnstableApiUsage") + +package com.google.firebase.dataconnect.gradle.plugin + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.DslExtension +import com.android.build.api.variant.VariantExtensionConfig +import java.util.Locale +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.logging.Logging +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.newInstance +import org.gradle.kotlin.dsl.register + +@Suppress("unused") +abstract class DataConnectGradlePlugin : Plugin { + + private val logger = Logging.getLogger(javaClass) + + override fun apply(project: Project) { + val android = + project.extensions.run { + findByType() + ?: findByType() + ?: throw DataConnectGradleException( + "b2a848r87f", + "Unable to find Android ApplicationExtension or LibraryExtension;" + + " ensure that the Android Gradle application or library plugin has been applied" + ) + } as ExtensionAware + + val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) + logger.info("Found Android Gradle Plugin version: {}", androidComponents.pluginVersion) + + androidComponents.registerSourceType("dataconnect") + + androidComponents.registerExtension( + DslExtension.Builder("dataconnect") + .extendBuildTypeWith(DataConnectDslExtension::class.java) + .extendProductFlavorWith(DataConnectDslExtension::class.java) + .extendProjectWith(DataConnectDslExtension::class.java) + .build() + ) { config: VariantExtensionConfig<*> -> + project.objects.newInstance(config) + } + + val dataConnectLocalSettings = DataConnectLocalSettings(project) + + androidComponents.onVariants { variant -> + val variantNameTitleCase = variant.name.replaceFirstChar { it.titlecase(Locale.US) } + val baseBuildDirectory: Provider = + project.layout.buildDirectory.dir("intermediates/dataconnect/${variant.name}") + + val dataConnectProviders = + DataConnectProviders( + project = project, + localSettings = dataConnectLocalSettings, + projectExtension = android.extensions.getByType(), + variantExtension = variant.getExtension(), + ) + + val downloadDataConnectExecutableTask = + project.tasks.register( + "download${variantNameTitleCase}DataConnectExecutable" + ) { + val dataConnectExecutable = dataConnectProviders.dataConnectExecutable + buildDirectory.set(baseBuildDirectory.map { it.dir("executable") }) + inputFile.set( + dataConnectExecutable.map( + TransformerInterop { + when (it) { + is DataConnectExecutable.File -> + project.layout.projectDirectory.file(it.file.path) + is DataConnectExecutable.RegularFile -> it.file + is DataConnectExecutable.Version -> null + } + } + ) + ) + version.set( + dataConnectExecutable.map( + TransformerInterop { + when (it) { + is DataConnectExecutable.File -> null + is DataConnectExecutable.RegularFile -> null + is DataConnectExecutable.Version -> it.version + } + } + ) + ) + verificationInfo.set( + dataConnectExecutable.map( + TransformerInterop { + when (it) { + is DataConnectExecutable.File -> it.verificationInfo + is DataConnectExecutable.RegularFile -> it.verificationInfo + is DataConnectExecutable.Version -> it.verificationInfo + } + } + ) + ) + outputFile.set( + dataConnectExecutable.map { + when (it) { + is DataConnectExecutable.File -> inputFile.get() + is DataConnectExecutable.RegularFile -> inputFile.get() + is DataConnectExecutable.Version -> + buildDirectory + .map { directory -> directory.file("dataconnect-v${it.version}") } + .get() + } + } + ) + } + + val defaultConfigDirectories = variant.sources.getByName("dataconnect").all + val customConfigDirectory = dataConnectProviders.customConfigDir + val allConfigDirectories = buildList { + addAll(defaultConfigDirectories.get()) + customConfigDirectory.orNull?.let { add(it) } + } + val existingConfigDirectories = allConfigDirectories.filter { it.asFile.exists() } + + val mergeConfigDirectoriesTask = + project.tasks.register( + "merge${variantNameTitleCase}DataConnectConfigDirs" + ) { + this.defaultConfigDirectories.set(defaultConfigDirectories) + this.customConfigDirectory.set(customConfigDirectory) + buildDirectory.set(baseBuildDirectory.map { it.dir("mergedConfigs") }) + if (existingConfigDirectories.size > 1) { + mergedDirectory.set(buildDirectory) + } + } + + project.tasks.register( + "run${variantNameTitleCase}DataConnectEmulator" + ) { + outputs.upToDateWhen { false } + buildDirectory.set(baseBuildDirectory.map { it.dir("runEmulator") }) + dataConnectExecutable.set(downloadDataConnectExecutableTask.flatMap { it.outputFile }) + if (existingConfigDirectories.size > 1) { + configDirectory.set(mergeConfigDirectoriesTask.flatMap { it.mergedDirectory }) + } else if (existingConfigDirectories.size == 1) { + configDirectory.set(existingConfigDirectories.single()) + } else { + configDirectory.set( + project.provider { + throw DataConnectGradleException( + "cvvz9b57qp", + "Cannot run the Data Connect emulator unless one or more config directories exist:" + + allConfigDirectories.joinToString(", ") + ) + } + ) + } + postgresConnectionUrl.set(dataConnectProviders.postgresConnectionUrl) + schemaExtensionsOutputEnabled.set(dataConnectProviders.schemaExtensionsOutputEnabled) + } + + val generateCodeTask = + project.tasks.register( + "generate${variantNameTitleCase}DataConnectSources" + ) { + dataConnectExecutable.set(downloadDataConnectExecutableTask.flatMap { it.outputFile }) + if (existingConfigDirectories.size > 1) { + configDirectory.set(mergeConfigDirectoriesTask.flatMap { it.mergedDirectory }) + } else if (existingConfigDirectories.size == 1) { + configDirectory.set(existingConfigDirectories.single()) + } + connectors.set(dataConnectProviders.connectors) + buildDirectory.set(baseBuildDirectory.map { it.dir("generateCode") }) + } + + variant.sources.java!!.addGeneratedSourceDirectory( + generateCodeTask, + DataConnectGenerateCodeTask::outputDirectory + ) + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectLocalSettings.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectLocalSettings.kt new file mode 100644 index 00000000000..cfa265bd409 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectLocalSettings.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.util.Properties +import org.gradle.api.Project +import org.gradle.api.provider.Provider + +class DataConnectLocalSettings(project: Project) { + + val dataConnectExecutableFile: Provider = + project + .providerForDataConnectLocalSettings( + KEY_DATA_CONNECT_EXECUTABLE_FILE, + KEY_DATA_CONNECT_EXECUTABLE_VERSION + ) { settingName, settingValue, project -> + if (settingName == KEY_DATA_CONNECT_EXECUTABLE_FILE) { + val regularFile = project.layout.projectDirectory.file(settingValue) + DataConnectExecutable.RegularFile(regularFile, verificationInfo = null) + } else if (settingName == KEY_DATA_CONNECT_EXECUTABLE_VERSION) { + DataConnectExecutable.Version.forVersionWithDefaultVerificationInfo(settingValue) + } else { + throw IllegalStateException( + "fileValue==null && versionValue==null (error code rbhmsd524t)" + ) + } + } + .map { settingValueByName -> + val executableFile = settingValueByName[KEY_DATA_CONNECT_EXECUTABLE_FILE] + val executableVersion = settingValueByName[KEY_DATA_CONNECT_EXECUTABLE_VERSION] + executableFile + ?: executableVersion + ?: throw IllegalStateException( + "executableFile==null && executableVersion==null (error code cn9ygjt55e)" + ) + } + + val postgresConnectionUrl: Provider = + project.providerForDataConnectLocalSetting(KEY_POSTGRES_CONNECTION_URL) + + val schemaExtensionsOutputEnabled: Provider = + project.providerForDataConnectLocalSetting(KEY_SCHEMA_EXTENSIONS_OUTPUT_ENABLED).map { + when (it) { + "1" -> true + "true" -> true + "0" -> false + "false" -> false + // TODO: Find a way to include the file name in th exception's message. + else -> + throw DataConnectGradleException( + "whrtqh5wvy", + "invalid value for $KEY_SCHEMA_EXTENSIONS_OUTPUT_ENABLED: $it" + + " (valid values are: 0, 1, true, false" + ) + } + } + + companion object { + const val FILE_NAME = "dataconnect.local.properties" + const val KEY_DATA_CONNECT_EXECUTABLE_FILE = "dataConnectExecutable.file" + const val KEY_DATA_CONNECT_EXECUTABLE_VERSION = "dataConnectExecutable.version" + const val KEY_POSTGRES_CONNECTION_URL = "emulator.postgresConnectionUrl" + const val KEY_SCHEMA_EXTENSIONS_OUTPUT_ENABLED = "emulator.schemaExtensionsOutputEnabled" + + fun Project.providerForDataConnectLocalSetting(settingName: String): Provider = + providerForDataConnectLocalSetting(settingName) { value, _ -> value } + + fun Project.providerForDataConnectLocalSetting( + settingName: String, + transformer: (String, Project) -> T + ): Provider = + providerForDataConnectLocalSettings(settingName) { _, settingValue, project -> + transformer(settingValue, project) + } + .map { it[settingName]!! } + + fun Project.providerForDataConnectLocalSettings( + firstSettingName: String, + vararg otherSettingNames: String, + transformer: (String, String, Project) -> T, + ): Provider> = + project.provider { + var curProject: Project? = project + while (curProject !== null) { + val settingValues = + curProject.settingValuesFromDataConnectLocalSettings( + firstSettingName, + *otherSettingNames + ) + if (settingValues.isNotEmpty()) { + return@provider settingValues.mapValues { entry -> + transformer(entry.key, entry.value, curProject!!) + } + } + curProject = curProject.parent + } + return@provider null + } + + private fun Project.settingValuesFromDataConnectLocalSettings( + firstSettingName: String, + vararg otherSettingNames: String + ): Map { + val localPropertiesFile = project.file(FILE_NAME) + logger.info( + "Looking for Data Connect local properties file: {}", + localPropertiesFile.absolutePath, + ) + + if (!localPropertiesFile.exists()) { + return emptyMap() + } + + logger.info("Loading Data Connect local settings file: {}", localPropertiesFile.absolutePath) + val properties = Properties() + localPropertiesFile.inputStream().use { properties.load(it) } + + val settingNames = buildList { + add(firstSettingName) + addAll(otherSettingNames) + } + val settingValueByName = mutableMapOf() + for (settingName in settingNames) { + val settingValue = properties.getProperty(settingName) + if (settingValue === null) { + logger.info( + "Setting \"{}\" not found in Data Connect local properties file: {}", + settingName, + localPropertiesFile.absolutePath, + ) + } else { + logger.info( + "Setting \"{}\" found in Data Connect local properties file {}: {}", + settingName, + localPropertiesFile.absolutePath, + settingValue, + ) + settingValueByName.put(settingName, settingValue) + } + } + + return settingValueByName + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectMergeConfigDirectoriesTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectMergeConfigDirectoriesTask.kt new file mode 100644 index 00000000000..fd07c5f735e --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectMergeConfigDirectoriesTask.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.File +import java.util.Locale +import org.gradle.api.DefaultTask +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +abstract class DataConnectMergeConfigDirectoriesTask : DefaultTask() { + + @get:InputFiles abstract val defaultConfigDirectories: ListProperty + + @get:InputFiles @get:Optional abstract val customConfigDirectory: DirectoryProperty + + @get:Internal abstract val buildDirectory: DirectoryProperty + + @get:OutputDirectory @get:Optional abstract val mergedDirectory: DirectoryProperty + + @TaskAction + fun run() { + val defaultConfigDirectories: List = + defaultConfigDirectories + .get() + .map { it.asFile } + .sortedBy { it.absolutePath.lowercase(Locale.US) } + val customConfigDirectory: File? = customConfigDirectory.orNull?.asFile + val buildDirectory: File = buildDirectory.get().asFile + val mergedDirectory: File? = mergedDirectory.orNull?.asFile + + logger.info( + "defaultConfigDirectories ({}): {}", + defaultConfigDirectories.size, + defaultConfigDirectories.map { it.absolutePath }.joinToString(", ") + ) + logger.info("customConfigDirectory: {}", customConfigDirectory?.absolutePath) + logger.info("buildDirectory: {}", buildDirectory.absolutePath) + logger.info("mergedDirectory: {}", mergedDirectory?.absolutePath) + + logger.info("Deleting build directory: {}", buildDirectory) + project.delete(buildDirectory) + + val configDirectories = + buildList { + addAll(defaultConfigDirectories) + if (customConfigDirectory !== null) { + add(customConfigDirectory) + if (!customConfigDirectory.exists()) { + throw DataConnectGradleException( + "chhzf62bwt", + "custom data connect config directory not found: " + + customConfigDirectory.absolutePath + ) + } + } + } + .sortedBy { it.absolutePath.lowercase(Locale.US) } + + val existingConfigDirectories = configDirectories.filter { it.exists() } + + if (mergedDirectory === null) { + if (existingConfigDirectories.size > 1) { + throw DataConnectGradleException( + "rft8texx22", + "'mergedDirectory' is null but existingConfigDirectories has more than one directory:" + + " (${existingConfigDirectories.size} directories) " + + existingConfigDirectories.joinToString(", ") + ) + } + // nothing to do, since the one-and-only existing config directory will be used directly. + return + } else if (existingConfigDirectories.isEmpty()) { + // nothing to do, since there are no existing config directories. + return + } else if (mergedDirectory != buildDirectory) { + throw DataConnectGradleException( + "qay4ngz5fr", + "mergedDirectory must equal buildDirectory" + + " when there are more than one existing config directories;" + + " however, they were unequal and there were ${existingConfigDirectories.size}" + + " existing config directories: " + + existingConfigDirectories.joinToString(", ") { it.absolutePath } + + " (mergedDirectory=$mergedDirectory buildDirectory=$buildDirectory)" + ) + } + + logger.info( + "Merging config directories {} to {}", + existingConfigDirectories.joinToString(", ") { it.absolutePath }, + mergedDirectory.absolutePath + ) + project.copy { + it.from(existingConfigDirectories) + it.into(mergedDirectory) + it.duplicatesStrategy = DuplicatesStrategy.FAIL + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt new file mode 100644 index 00000000000..0adf63688bc --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider + +class DataConnectProviders( + project: Project, + localSettings: DataConnectLocalSettings, + projectExtension: DataConnectDslExtension, + variantExtension: DataConnectVariantDslExtension +) { + + val dataConnectExecutable: Provider = run { + val fileGradlePropertyName = "dataconnect.dataConnectExecutable.file" + val versionGradlePropertyName = "dataconnect.dataConnectExecutable.version" + + val valueFromLocalSettings: Provider = + localSettings.dataConnectExecutableFile + val fileValueFromGradleProperty: Provider = + project.providers.gradleProperty(fileGradlePropertyName).map { + val regularFile = project.layout.projectDirectory.file(it) + DataConnectExecutable.RegularFile(regularFile, verificationInfo = null) + } + val versionValueFromGradleProperty: Provider = + project.providers.gradleProperty(versionGradlePropertyName).map { + DataConnectExecutable.Version.forVersionWithDefaultVerificationInfo(it) + } + val valueFromVariant: Provider = variantExtension.dataConnectExecutable + val valueFromProject: Provider = + project.provider { projectExtension.dataConnectExecutable } + + valueFromLocalSettings + .orElse(fileValueFromGradleProperty) + .orElse(versionValueFromGradleProperty) + .orElse(valueFromVariant) + .orElse(valueFromProject) + .orElse(DataConnectExecutable.Version.forVersionWithDefaultVerificationInfo("1.3.8")) + } + + val postgresConnectionUrl: Provider = run { + val gradlePropertyName = "dataconnect.emulator.postgresConnectionUrl" + val valueFromLocalSettings: Provider = localSettings.postgresConnectionUrl + val valueFromGradleProperty: Provider = + project.providers.gradleProperty(gradlePropertyName) + val valueFromVariant: Provider = variantExtension.emulator.postgresConnectionUrl + val valueFromProject: Provider = + project.provider { projectExtension.emulator.postgresConnectionUrl } + + valueFromLocalSettings + .orElse(valueFromGradleProperty) + .orElse(valueFromVariant) + .orElse(valueFromProject) + .orElse( + project.provider { + throw DataConnectGradleException( + "m6hbyq6j3b", + "postgresConnectionUrl is not set;" + + " try setting android.dataconnect.emulator.postgresConnectionUrl=\"postgresql://...\"" + + " in build.gradle or build.gradle.kts," + + " setting the $gradlePropertyName project property," + + " such as by specifying -P${gradlePropertyName}=postgresql://... on the Gradle command line," + + " or setting ${DataConnectLocalSettings.KEY_POSTGRES_CONNECTION_URL}=postgresql://..." + + " in ${project.file(DataConnectLocalSettings.FILE_NAME)};" + + " an example value is postgresql://postgres:postgres@localhost:5432?sslmode=disable" + ) + } + ) + } + + val schemaExtensionsOutputEnabled: Provider = run { + val gradlePropertyName = "dataconnect.emulator.schemaExtensionsOutputEnabled" + val valueFromLocalSettings: Provider = localSettings.schemaExtensionsOutputEnabled + val valueFromGradleProperty: Provider = + project.providers.gradleProperty(gradlePropertyName).map { + when (it) { + "1" -> true + "true" -> true + "0" -> false + "false" -> false + else -> + throw DataConnectGradleException( + "shc2xwypgf", + "invalid value for gradle property $gradlePropertyName: $it" + + " (valid values are: 0, 1, true, false" + ) + } + } + val valueFromVariant: Provider = + variantExtension.emulator.schemaExtensionsOutputEnabled + val valueFromProject: Provider = + project.provider { projectExtension.emulator.schemaExtensionsOutputEnabled } + + valueFromLocalSettings + .orElse(valueFromGradleProperty) + .orElse(valueFromVariant) + .orElse(valueFromProject) + } + + val customConfigDir: Provider = run { + val valueFromVariant: Provider = project.layout.dir(variantExtension.configDir) + val valueFromProject: Provider = + project.provider { + projectExtension.configDir?.let { file -> + project.objects.directoryProperty().apply { set(file) }.get() + } + } + + valueFromVariant.orElse(valueFromProject) + } + + val connectors: Provider> = run { + val valueFromVariant: Provider> = variantExtension.codegen.connectors + val valueFromProject: Provider> = + project.provider { projectExtension.codegen.connectors } + + valueFromVariant.orElse(valueFromProject) + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectRunEmulatorTask.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectRunEmulatorTask.kt new file mode 100644 index 00000000000..775421f1afc --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectRunEmulatorTask.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.io.File +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction + +abstract class DataConnectRunEmulatorTask : DefaultTask() { + + @get:InputFile abstract val dataConnectExecutable: RegularFileProperty + + @get:InputDirectory abstract val configDirectory: DirectoryProperty + + @get:Input abstract val postgresConnectionUrl: Property + + @get:Input abstract val schemaExtensionsOutputEnabled: Property + + @get:Internal abstract val buildDirectory: DirectoryProperty + + @TaskAction + fun run() { + val dataConnectExecutable: File = dataConnectExecutable.get().asFile + val configDirectory: File = configDirectory.get().asFile + val postgresConnectionUrl: String = postgresConnectionUrl.get() + val schemaExtensionsOutputEnabled: Boolean = schemaExtensionsOutputEnabled.get() + val buildDirectory: File = buildDirectory.get().asFile + + logger.info("dataConnectExecutable={}", dataConnectExecutable.absolutePath) + logger.info("configDirectory={}", configDirectory.absolutePath) + logger.info("postgresConnectionUrl={}", postgresConnectionUrl) + logger.info("schemaExtensionsOutputEnabled={}", schemaExtensionsOutputEnabled) + logger.info("buildDirectory={}", buildDirectory) + + runDataConnectExecutable( + dataConnectExecutable = dataConnectExecutable, + subCommand = listOf("dev"), + configDirectory = configDirectory, + ) { + this.listen = "127.0.0.1:9399" + this.localConnectionString = postgresConnectionUrl + this.logFile = File(buildDirectory, "log.txt") + this.schemaExtensionsOutputEnabled = schemaExtensionsOutputEnabled + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectVariantDslExtension.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectVariantDslExtension.kt new file mode 100644 index 00000000000..c9ffa312a57 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectVariantDslExtension.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("LeakingThis") + +package com.google.firebase.dataconnect.gradle.plugin + +import com.android.build.api.variant.Variant +import com.android.build.api.variant.VariantExtension +import com.android.build.api.variant.VariantExtensionConfig +import com.google.firebase.dataconnect.gradle.plugin.DataConnectDslExtension.DataConnectCodegenDslExtension +import com.google.firebase.dataconnect.gradle.plugin.DataConnectDslExtension.DataConnectEmulatorDslExtension +import java.io.File +import javax.inject.Inject +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.kotlin.dsl.* + +/** + * This is the extension type for extending [com.android.build.api.variant.Variant]. + * + * There will be an instance of this type for each variant and the instance can be retrieved using + * the [com.android.build.api.variant.Variant.getExtension] method. + * + * Variant objects and custom extensions can be passed to multiple plugins that have registered a + * block with the `androidComponents.onVariants` method. Each plugin can reset the variant's field + * values set by a predecessor in the invocation order (usually the registration order). Therefore, + * all variant custom extension should use `org.gradle.api.provider.Property` for their fields. + * These `org.gradle.api.provider.Property` can then be used as Task's Input and be sure to obtain + * the last value set even if the value was reset after the Task was created. + * + * The instance is created by providing a configuration block to the + * [com.android.build.api.variant.AndroidComponentsExtension.registerExtension] method. + */ +@Suppress("UnstableApiUsage") +abstract class DataConnectVariantDslExtension( + variant: Variant, + buildTypeExtension: DataConnectDslExtension, + productFlavorExtensions: List, + objectFactory: ObjectFactory, +) : VariantExtension { + + @Inject + @Suppress("unused") + constructor( + extensionConfig: VariantExtensionConfig<*>, + objectFactory: ObjectFactory + ) : this( + extensionConfig.variant, + extensionConfig.buildTypeExtension(), + extensionConfig.productFlavorsExtensions(), + objectFactory, + ) + + /** @see DataConnectDslExtension.configDir */ + abstract val configDir: Property + init { + configDir.setFrom( + variant, + buildTypeExtension, + productFlavorExtensions, + "configDir", + DataConnectDslExtension::configDir, + ) + } + + /** @see DataConnectDslExtension.dataConnectExecutable */ + abstract val dataConnectExecutable: Property + init { + dataConnectExecutable.setFrom( + variant, + buildTypeExtension, + productFlavorExtensions, + "dataConnectExecutable", + DataConnectDslExtension::dataConnectExecutable, + ) + } + + /** @see DataConnectDslExtension.codegen */ + val codegen: DataConnectCodegenVariantDslExtension = + objectFactory.newInstance( + variant, + buildTypeExtension.codegen, + productFlavorExtensions.map { it.codegen }, + ) + + /** @see DataConnectDslExtension.emulator */ + val emulator: DataConnectEmulatorVariantDslExtension = + objectFactory.newInstance( + variant, + buildTypeExtension.emulator, + productFlavorExtensions.map { it.emulator }, + ) + + /** Values to use when performing code generation. */ + abstract class DataConnectCodegenVariantDslExtension + @Inject + constructor( + variant: Variant, + buildTypeExtension: DataConnectCodegenDslExtension, + productFlavorExtensions: List, + ) { + /** @see DataConnectCodegenDslExtension.connectors */ + abstract val connectors: Property> + init { + connectors.setFrom( + variant, + buildTypeExtension, + productFlavorExtensions, + "connectors", + DataConnectCodegenDslExtension::connectors, + ) + } + } + + /** Values to use when running the Data Connect emulator. */ + abstract class DataConnectEmulatorVariantDslExtension + @Inject + constructor( + variant: Variant, + buildTypeExtension: DataConnectEmulatorDslExtension, + productFlavorExtensions: List, + ) { + /** @see DataConnectEmulatorDslExtension.postgresConnectionUrl */ + abstract val postgresConnectionUrl: Property + init { + postgresConnectionUrl.setFrom( + variant, + buildTypeExtension, + productFlavorExtensions, + "postgresConnectionUrl", + DataConnectEmulatorDslExtension::postgresConnectionUrl + ) + } + + /** @see DataConnectEmulatorDslExtension.schemaExtensionsOutputEnabled */ + abstract val schemaExtensionsOutputEnabled: Property + init { + schemaExtensionsOutputEnabled.setFrom( + variant, + buildTypeExtension, + productFlavorExtensions, + "schemaExtensionsOutputEnabled", + DataConnectEmulatorDslExtension::schemaExtensionsOutputEnabled + ) + } + } + + private companion object { + + fun Property.setFrom( + variant: Variant, + buildTypeExtension: ExtensionType, + productFlavorExtensions: List, + name: String, + getValue: (ExtensionType) -> PropertyType?, + ) { + val values = buildMap { + getValue(buildTypeExtension)?.let { put("BuildType:${variant.buildType}", it) } + val productFlavorNames = variant.productFlavors.map { "${it.first}=${it.second}" } + productFlavorExtensions.forEachIndexed { i, productFlavorExtension -> + getValue(productFlavorExtension)?.let { + put("ProductFlavor:${productFlavorNames[i]}", it) + } + } + } + + if (values.size == 1) { + set(values.values.single()) + } else if (values.size > 1) { + throw DataConnectGradleException( + "z9hmj4bmgs", + "$name is specified in ${values.size} places," + + " but at most one is supported: " + + values.keys.sorted().joinToString(", ") + ) + } + } + } +} diff --git a/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/Util.kt b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/Util.kt new file mode 100644 index 00000000000..1fe3b5e57ce --- /dev/null +++ b/firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/Util.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.gradle.plugin + +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +fun toHexString(bytes: ByteArray): String = buildString { + for (b in bytes) { + append(String.format("%02x", b)) + } +} + +fun Long.toStringWithThousandsSeparator(): String = String.format(Locale.US, "%,d", this) + +class Debouncer(val period: Duration) { + + private val lastLogTime = AtomicLong(Long.MIN_VALUE) + + fun debounce(): Boolean { + val currentTime = System.nanoTime() + val capturedLastLogTime = lastLogTime.get() + val timeSinceLastLog = (currentTime - capturedLastLogTime).toDuration(DurationUnit.NANOSECONDS) + return timeSinceLastLog >= period && lastLogTime.compareAndSet(capturedLastLogTime, currentTime) + } + + inline fun maybeRun(block: () -> T) { + if (debounce()) { + block() + } + } +} diff --git a/firebase-dataconnect/gradleplugin/settings.gradle.kts b/firebase-dataconnect/gradleplugin/settings.gradle.kts new file mode 100644 index 00000000000..c118ef47580 --- /dev/null +++ b/firebase-dataconnect/gradleplugin/settings.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +rootProject.name = "dataconnect-gradle-plugin" + +pluginManagement { + repositories { + maven { url = uri("") } + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + maven { url = uri("") } + google() + mavenCentral() + } +} + +include(":plugin") diff --git a/firebase-dataconnect/lint.xml b/firebase-dataconnect/lint.xml new file mode 100644 index 00000000000..eb97348e7a1 --- /dev/null +++ b/firebase-dataconnect/lint.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/firebase-dataconnect/scripts/compile_kotlin.sh b/firebase-dataconnect/scripts/compile_kotlin.sh new file mode 100755 index 00000000000..479099500a6 --- /dev/null +++ b/firebase-dataconnect/scripts/compile_kotlin.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -gt 0 ]] ; then + echo "ERROR: no command-line arguments are supported, but got $*" >&2 + exit 2 +fi + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly TARGETS=( + ":firebase-dataconnect:compileDebugKotlin" + ":firebase-dataconnect:compileDebugUnitTestKotlin" + ":firebase-dataconnect:compileDebugAndroidTestKotlin" + ":firebase-dataconnect:androidTestutil:compileDebugKotlin" + ":firebase-dataconnect:androidTestutil:compileDebugUnitTestKotlin" + ":firebase-dataconnect:androidTestutil:compileDebugAndroidTestKotlin" + ":firebase-dataconnect:connectors:compileDebugKotlin" + ":firebase-dataconnect:connectors:compileDebugUnitTestKotlin" + ":firebase-dataconnect:connectors:compileDebugAndroidTestKotlin" + ":firebase-dataconnect:testutil:compileDebugKotlin" + ":firebase-dataconnect:testutil:compileDebugUnitTestKotlin" + ":firebase-dataconnect:testutil:compileDebugAndroidTestKotlin" +) + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "${TARGETS[@]}" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/scripts/run_all_tests.sh b/firebase-dataconnect/scripts/run_all_tests.sh new file mode 100755 index 00000000000..bba852dbdc6 --- /dev/null +++ b/firebase-dataconnect/scripts/run_all_tests.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -gt 0 ]] ; then + echo "ERROR: no command-line arguments are supported, but got $*" >&2 + exit 2 +fi + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly TARGETS=( + ":firebase-dataconnect:androidTestutil:connectedDebugAndroidTest" + ":firebase-dataconnect:androidTestutil:testDebugUnitTest" + ":firebase-dataconnect:connectedDebugAndroidTest" + ":firebase-dataconnect:connectors:connectedDebugAndroidTest" + ":firebase-dataconnect:connectors:testDebugUnitTest" + ":firebase-dataconnect:testDebugUnitTest" + ":firebase-dataconnect:testutil:connectedDebugAndroidTest" + ":firebase-dataconnect:testutil:testDebugUnitTest" +) + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "${TARGETS[@]}" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/scripts/run_integration_tests.sh b/firebase-dataconnect/scripts/run_integration_tests.sh new file mode 100755 index 00000000000..33e41603ca2 --- /dev/null +++ b/firebase-dataconnect/scripts/run_integration_tests.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -gt 0 ]] ; then + echo "ERROR: no command-line arguments are supported, but got $*" >&2 + exit 2 +fi + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly TARGETS=( + ":firebase-dataconnect:connectedDebugAndroidTest" + ":firebase-dataconnect:androidTestutil:connectedDebugAndroidTest" + ":firebase-dataconnect:connectors:connectedDebugAndroidTest" + ":firebase-dataconnect:testutil:connectedDebugAndroidTest" +) + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "${TARGETS[@]}" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/scripts/run_unit_tests.sh b/firebase-dataconnect/scripts/run_unit_tests.sh new file mode 100755 index 00000000000..6bbc8a52dbb --- /dev/null +++ b/firebase-dataconnect/scripts/run_unit_tests.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -gt 0 ]] ; then + echo "ERROR: no command-line arguments are supported, but got $*" >&2 + exit 2 +fi + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly TARGETS=( + ":firebase-dataconnect:testDebugUnitTest" + ":firebase-dataconnect:androidTestutil:testDebugUnitTest" + ":firebase-dataconnect:connectors:testDebugUnitTest" + ":firebase-dataconnect:testutil:testDebugUnitTest" +) + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "${TARGETS[@]}" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/scripts/spotlessApply.sh b/firebase-dataconnect/scripts/spotlessApply.sh new file mode 100755 index 00000000000..162794bbc1c --- /dev/null +++ b/firebase-dataconnect/scripts/spotlessApply.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +if [[ $# -gt 0 ]] ; then + echo "ERROR: no command-line arguments are supported, but got $*" >&2 + exit 2 +fi + +readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.." + +readonly TARGETS=( + ":firebase-dataconnect:spotlessApply" + ":firebase-dataconnect:androidTestutil:spotlessApply" + ":firebase-dataconnect:connectors:spotlessApply" + ":firebase-dataconnect:testutil:spotlessApply" +) + +readonly args=( + "${PROJECT_ROOT_DIR}/gradlew" + "-p" + "${PROJECT_ROOT_DIR}" + "--configure-on-demand" + "${TARGETS[@]}" +) + +echo "${args[*]}" +exec "${args[@]}" diff --git a/firebase-dataconnect/src/androidTest/AndroidManifest.xml b/firebase-dataconnect/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000000..0e8d7a8910b --- /dev/null +++ b/firebase-dataconnect/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt new file mode 100644 index 00000000000..3b30aa8d9d9 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt @@ -0,0 +1,1089 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalKotest::class) +@file:UseSerializers(UUIDSerializer::class) + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.serializers.UUIDSerializer +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.EdgeCases +import com.google.firebase.dataconnect.testutil.anyListScalar +import com.google.firebase.dataconnect.testutil.anyScalar +import com.google.firebase.dataconnect.testutil.expectedAnyScalarRoundTripValue +import com.google.firebase.dataconnect.testutil.filterNotAnyScalarMatching +import com.google.firebase.dataconnect.testutil.filterNotIncludesAllMatchingAnyScalars +import com.google.firebase.dataconnect.testutil.filterNotNull +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContainIgnoringCase +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.orNull +import io.kotest.property.checkAll +import java.util.UUID +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.serializer +import org.junit.Test + +class AnyScalarIntegrationTest : DataConnectIntegrationTestBase() { + + private val dataConnect: FirebaseDataConnect by lazy { + val connectorConfig = testConnectorConfig.copy(connector = "demo") + dataConnectFactory.newInstance(connectorConfig) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullable @table { value: Any!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars.filterNotNull()) { + withClue("value=$value") { + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableGetByKey", + ) + } + } + } + } + + @Test + fun anyScalarNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars.filterNotNull()) { + val otherValues = Arb.anyScalar().filterNotNull().filterNotAnyScalarMatching(value) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + otherValues.next(), + otherValues.next(), + insert3MutationName = "AnyScalarNonNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNonNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar().filterNotNull()) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar().filterNotNull()) { value -> + val otherValues = Arb.anyScalar().filterNotNull().filterNotAnyScalarMatching(value) + verifyAnyScalarQueryVariable( + value, + otherValues.next(), + otherValues.next(), + insert3MutationName = "AnyScalarNonNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNonNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNonNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + verifyMutationWithMissingAnyVariableFails("AnyScalarNonNullableInsert") + } + + @Test + fun anyScalarNonNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + verifyQueryWithMissingAnyVariableFails("AnyScalarNonNullableGetAllByTagAndValue") + } + + @Test + fun anyScalarNonNullable_MutationFailsIfAnyVariableIsNull() = runTest { + verifyMutationWithNullAnyVariableFails("AnyScalarNonNullableInsert") + } + + @Test + fun anyScalarNonNullable_QueryFailsIfAnyVariableIsNull() = runTest { + verifyQueryWithNullAnyVariableFails("AnyScalarNonNullableGetAllByTagAndValue") + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullable @table { value: Any, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars) { + withClue("value=$value") { + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNullableInsert", + getByKeyQueryName = "AnyScalarNullableGetByKey", + ) + } + } + } + } + + @Test + fun anyScalarNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.anyScalars) { + val otherValues = Arb.anyScalar().filterNotAnyScalarMatching(value) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + otherValues.next(), + otherValues.next(), + insert3MutationName = "AnyScalarNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar()) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNullableInsert", + getByKeyQueryName = "AnyScalarNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + checkAll(normalCasePropTestConfig, Arb.anyScalar()) { value -> + val otherValues = Arb.anyScalar().filterNotAnyScalarMatching(value) + verifyAnyScalarQueryVariable( + value, + otherValues.next(), + otherValues.next(), + insert3MutationName = "AnyScalarNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + verifyMutationWithMissingAnyVariableSucceeds( + insertMutationName = "AnyScalarNullableInsert", + getByKeyQueryName = "AnyScalarNullableGetByKey", + ) + } + + @Test + fun anyScalarNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + // TODO: factor this out to a reusable method + val values = Arb.anyScalar() + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNullableInsert3", + tag, + values.next(), + values.next(), + values.next() + ) + + val queryRef = + dataConnect.query( + operationName = "AnyScalarNullableGetAllByTagAndValue", + variables = DataConnectUntypedVariables("tag" to tag), + dataDeserializer = DataConnectUntypedData, + variablesSerializer = DataConnectUntypedVariables, + ) + val queryResult = queryRef.execute() + queryResult.data.data shouldBe + mapOf( + "items" to + listOf( + mapOf("id" to keys.key1.id), + mapOf("id" to keys.key2.id), + mapOf("id" to keys.key3.id) + ) + ) + queryResult.data.errors.shouldBeEmpty() + } + + @Test + fun anyScalarNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + verifyMutationWithNullAnyVariableSucceeds( + insertMutationName = "AnyScalarNullableInsert", + getByKeyQueryName = "AnyScalarNullableGetByKey", + ) + } + + @Test + fun anyScalarNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + // TODO: factor this out to a reusable method + val values = Arb.anyScalar().filter { it !== null } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation("AnyScalarNullableInsert3", tag, null, values.next(), values.next()) + + val queryRef = + dataConnect.query( + operationName = "AnyScalarNullableGetAllByTagAndValue", + variables = DataConnectUntypedVariables("tag" to tag, "value" to null), + dataDeserializer = DataConnectUntypedData, + variablesSerializer = DataConnectUntypedVariables, + ) + val queryResult = queryRef.execute() + queryResult.data.data shouldBe mapOf("items" to listOf(mapOf("id" to keys.key1.id))) + queryResult.data.errors.shouldBeEmpty() + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullableListOfNullable @table { value: [Any], tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullableListOfNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { + val key = executeInsertMutation("AnyScalarNullableListOfNullableInsert", value) + val expectedQueryResult = expectedAnyScalarRoundTripValue(value) + verifyQueryResult2("AnyScalarNullableListOfNullableGetByKey", key, expectedQueryResult) + } + } + } + } + + @Test + fun anyScalarNullableListOfNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNullableListOfNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableListOfNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNullableListOfNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNullableListOfNullableInsert", + getByKeyQueryName = "AnyScalarNullableListOfNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNullableListOfNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNullableListOfNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableListOfNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNullableListOfNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + val key = executeInsertMutation("AnyScalarNullableListOfNullableInsert", EmptyVariables) + verifyQueryResult2("AnyScalarNullableListOfNullableGetByKey", key, null) + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNullableListOfNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + val keyIds = listOf(keys.key1, keys.key2, keys.key3).map { it.id } + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNullableGetAllByTagAndValue", + tag, + OmitValue + ) + val queryIds = queryResult.map { it.id } + queryIds shouldContainExactlyInAnyOrder keyIds + } + + @Test + fun anyScalarNullableListOfNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + val key = executeInsertMutation("AnyScalarNullableListOfNullableInsert", null) + verifyQueryResult2("AnyScalarNullableListOfNullableGetByKey", key, null) + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + executeInsert3Mutation( + "AnyScalarNullableListOfNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + .key1 + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNullableGetAllByTagAndValue", + tag, + null + ) + queryResult.shouldBeEmpty() + } + + @Test + fun anyScalarNullableListOfNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNullableListOfNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNullableGetAllByTagAndValue", + tag, + emptyList() + ) + val queryIds = queryResult.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key2.id, keys.key3.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNullableListOfNonNullable @table { value: [Any!], tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNullableListOfNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() } + for (value in edgeCases) { + withClue("value=$value") { + val key = executeInsertMutation("AnyScalarNullableListOfNonNullableInsert", value) + val expectedQueryResult = expectedAnyScalarRoundTripValue(value) + verifyQueryResult2( + "AnyScalarNullableListOfNonNullableGetByKey", + key, + expectedQueryResult + ) + } + } + } + } + + @Test + fun anyScalarNullableListOfNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNullableListOfNonNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableListOfNonNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNullableListOfNonNullableInsert", + getByKeyQueryName = "AnyScalarNullableListOfNonNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNullableListOfNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = + otherValues.filterNotIncludesAllMatchingAnyScalars(value).orNull(nullProbability = 0.1) + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNullableListOfNonNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNullableListOfNonNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationSucceedsIfAnyVariableIsMissing() = runTest { + val key = executeInsertMutation("AnyScalarNullableListOfNonNullableInsert", EmptyVariables) + verifyQueryResult2("AnyScalarNullableListOfNonNullableGetByKey", key, null) + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsMissing() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNullableListOfNonNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + val keyIds = listOf(keys.key1, keys.key2, keys.key3).map { it.id } + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNonNullableGetAllByTagAndValue", + tag, + OmitValue + ) + val queryIds = queryResult.map { it.id } + queryIds shouldContainExactlyInAnyOrder keyIds + } + + @Test + fun anyScalarNullableListOfNonNullable_MutationSucceedsIfAnyVariableIsNull() = runTest { + val key = executeInsertMutation("AnyScalarNullableListOfNonNullableInsert", null) + verifyQueryResult2("AnyScalarNullableListOfNonNullableGetByKey", key, null) + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsNull() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + executeInsert3Mutation( + "AnyScalarNullableListOfNonNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + .key1 + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNonNullableGetAllByTagAndValue", + tag, + null + ) + queryResult.shouldBeEmpty() + } + + @Test + fun anyScalarNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNullableListOfNonNullableInsert3", + tag, + null, + emptyList(), + values.next() + ) + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNullableListOfNonNullableGetAllByTagAndValue", + tag, + emptyList() + ) + val queryIds = queryResult.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key2.id, keys.key3.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullableListOfNullable @table { value: [Any]!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullableListOfNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.lists.map { it.filterNotNull() }) { + withClue("value=$value") { + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableListOfNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableListOfNullableGetByKey", + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNonNullableListOfNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNonNullableListOfNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableListOfNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableListOfNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNonNullableListOfNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNonNullableListOfNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + verifyMutationWithMissingAnyVariableFails("AnyScalarNonNullableListOfNullableInsert") + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + verifyQueryWithMissingAnyVariableFails("AnyScalarNonNullableListOfNullableGetAllByTagAndValue") + } + + @Test + fun anyScalarNonNullableListOfNullable_MutationFailsIfAnyVariableIsNull() = runTest { + verifyMutationWithNullAnyVariableFails("AnyScalarNonNullableListOfNullableInsert") + } + + @Test + fun anyScalarNonNullableListOfNullable_QueryFailsIfAnyVariableIsNull() = runTest { + verifyQueryWithNullAnyVariableFails("AnyScalarNonNullableListOfNullableGetAllByTagAndValue") + } + + @Test + fun anyScalarNonNullableListOfNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNonNullableListOfNullableInsert3", + tag, + values.next(), + emptyList(), + values.next() + ) + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNonNullableListOfNullableGetAllByTagAndValue", + tag, + emptyList() + ) + val queryIds = queryResult.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id, keys.key2.id, keys.key3.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type AnyScalarNonNullableListOfNonNullable @table { + // value: [Any!]!, tag: String, position: Int } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationVariableEdgeCases() = + runTest(timeout = 60.seconds) { + assertSoftly { + for (value in EdgeCases.lists.map { it.filterNotNull() }) { + withClue("value=$value") { + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableListOfNonNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableListOfNonNullableGetByKey", + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryVariableEdgeCases() = + runTest(timeout = 60.seconds) { + val edgeCases = EdgeCases.lists.map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + assertSoftly { + for (value in edgeCases) { + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + withClue("value=$value") { + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNonNullableListOfNonNullableInsert3", + getAllByTagAndValueQueryName = + "AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue" + ) + } + } + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() } + checkAll(normalCasePropTestConfig, values) { value -> + verifyAnyScalarRoundTrip( + value, + insertMutationName = "AnyScalarNonNullableListOfNonNullableInsert", + getByKeyQueryName = "AnyScalarNonNullableListOfNonNullableGetByKey", + ) + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryVariableNormalCases() = + runTest(timeout = 60.seconds) { + val values = Arb.anyListScalar().map { it.filterNotNull() }.filter { it.isNotEmpty() } + val otherValues = Arb.anyListScalar().map { it.filterNotNull() } + + checkAll(normalCasePropTestConfig, values) { value -> + val curOtherValues = otherValues.filterNotIncludesAllMatchingAnyScalars(value) + verifyAnyScalarQueryVariable( + value, + curOtherValues.next(), + curOtherValues.next(), + insert3MutationName = "AnyScalarNonNullableListOfNonNullableInsert3", + getAllByTagAndValueQueryName = "AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue" + ) + } + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationFailsIfAnyVariableIsMissing() = runTest { + verifyMutationWithMissingAnyVariableFails("AnyScalarNonNullableListOfNonNullableInsert") + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryFailsIfAnyVariableIsMissing() = runTest { + verifyQueryWithMissingAnyVariableFails( + "AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue" + ) + } + + @Test + fun anyScalarNonNullableListOfNonNullable_MutationFailsIfAnyVariableIsNull() = runTest { + verifyMutationWithNullAnyVariableFails("AnyScalarNonNullableListOfNonNullableInsert") + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QueryFailsIfAnyVariableIsNull() = runTest { + verifyQueryWithNullAnyVariableFails("AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue") + } + + @Test + fun anyScalarNonNullableListOfNonNullable_QuerySucceedsIfAnyVariableIsEmpty() = runTest { + val values = Arb.anyListScalar().map { it.filterNotNull() } + val tag = UUID.randomUUID().toString() + val keys = + executeInsert3Mutation( + "AnyScalarNonNullableListOfNonNullableInsert3", + tag, + values.next(), + emptyList(), + values.next() + ) + val queryResult = + executeGetAllByTagAndValueQuery( + "AnyScalarNonNullableListOfNonNullableGetAllByTagAndValue", + tag, + emptyList() + ) + val queryIds = queryResult.map { it.id } + queryIds.shouldContainExactlyInAnyOrder(keys.key1.id, keys.key2.id, keys.key3.id) + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // End of tests; everything below is helper functions and classes. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + object EmptyVariables + + /** + * Verifies that a value used as an `Any` scalar specified as a variable to a mutation is handled + * correctly. This is done by specifying the `Any` scalar value as a variable to a mutation that + * inserts a row into a table, followed by querying that row by its key to ensure that an equal + * `Any` value comes back from the query. + * + * @param value The value of the `Any` scalar to use; must be `null`, a [Boolean], [String], + * [Double], or a [Map], or [List] composed of these types. + * @param insertMutationName The operation name of a GraphQL mutation that takes a single variable + * named "value" of type `Any` or `[Any]`, with any nullability; this mutation must insert a row + * into a table and return a key for that row, where the key is a single "id" of type `UUID`. + * @param getByKeyQueryName The operation name of a GraphQL query that takes a single variable + * named "key" whose value is the key type returned from the `insertMutationName` mutation; its + * selection set must have a single field named "item" whose value is the `Any` value specified to + * the `insertMutationName` mutation. + */ + private suspend fun verifyAnyScalarRoundTrip( + value: Any?, + insertMutationName: String, + getByKeyQueryName: String, + ) { + val key = executeInsertMutation(insertMutationName, value) + val expectedQueryResult = expectedAnyScalarRoundTripValue(value) + verifyQueryResult2(getByKeyQueryName, key, expectedQueryResult) + } + + private suspend fun verifyAnyScalarQueryVariable( + value: Any?, + value2: Any?, + value3: Any?, + insert3MutationName: String, + getAllByTagAndValueQueryName: String, + ) { + require(value != value2) + require(value != value3) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value2)) + require(expectedAnyScalarRoundTripValue(value) != expectedAnyScalarRoundTripValue(value3)) + + val tag = UUID.randomUUID().toString() + val key = executeInsert3Mutation(insert3MutationName, tag, value, value2, value3).key1 + + val queryResult = executeGetAllByTagAndValueQuery(getAllByTagAndValueQueryName, tag, value) + queryResult.shouldContainExactlyInAnyOrder(key) + } + + private inline fun mutationRefForVariables( + operationName: String, + variables: Map, + dataDeserializer: DeserializationStrategy, + ): MutationRef = + dataConnect.mutation( + operationName = operationName, + variables = DataConnectUntypedVariables(variables), + dataDeserializer, + DataConnectUntypedVariables, + ) + + private inline fun queryRefForVariables( + operationName: String, + variables: Map, + dataDeserializer: DeserializationStrategy, + ): QueryRef = + dataConnect.query( + operationName = operationName, + variables = DataConnectUntypedVariables(variables), + dataDeserializer, + DataConnectUntypedVariables, + ) + + private inline fun mutationRefForVariable( + operationName: String, + variable: Any?, + dataDeserializer: DeserializationStrategy, + ): MutationRef = + mutationRefForVariables(operationName, mapOf("value" to variable), dataDeserializer) + + private inline fun queryRefForVariable( + operationName: String, + variable: Any?, + dataDeserializer: DeserializationStrategy, + ): QueryRef = + queryRefForVariables(operationName, mapOf("value" to variable), dataDeserializer) + + private suspend fun verifyMutationWithNullAnyVariableFails(operationName: String) { + val mutationRef = mutationRefForVariable(operationName, null, DataConnectUntypedData) + mutationRef.verifyExecuteFailsDueToNullVariable() + } + + private suspend fun verifyQueryWithNullAnyVariableFails(operationName: String) { + val queryRef = queryRefForVariable(operationName, null, DataConnectUntypedData) + queryRef.verifyExecuteFailsDueToNullVariable() + } + + private suspend fun verifyMutationWithNullAnyVariableSucceeds( + insertMutationName: String, + getByKeyQueryName: String, + ) { + val key = executeInsertMutation(insertMutationName, null) + verifyQueryResult2(getByKeyQueryName, key, null) + } + + private suspend fun OperationRef + .verifyExecuteFailsDueToNullVariable() { + val result = execute() + result.data.asClue { + it.data.shouldBeNull() + it.errors.shouldHaveAtLeastSize(1) + it.errors[0].message shouldContainIgnoringCase "\$value is null" + } + } + + private suspend fun verifyMutationWithMissingAnyVariableFails(operationName: String) { + val variables: Map = emptyMap() + val mutationRef = mutationRefForVariables(operationName, variables, DataConnectUntypedData) + mutationRef.verifyExecuteFailsDueToMissingVariable() + } + + private suspend fun verifyQueryWithMissingAnyVariableFails(operationName: String) { + val variables: Map = emptyMap() + val queryRef = queryRefForVariables(operationName, variables, DataConnectUntypedData) + queryRef.verifyExecuteFailsDueToMissingVariable() + } + + private suspend fun verifyMutationWithMissingAnyVariableSucceeds( + insertMutationName: String, + getByKeyQueryName: String, + ) { + val key = executeInsertMutation(insertMutationName, EmptyVariables) + verifyQueryResult2(getByKeyQueryName, key, null) + } + + private suspend fun OperationRef + .verifyExecuteFailsDueToMissingVariable() { + val result = execute() + result.data.asClue { + it.data.shouldBeNull() + it.errors.shouldHaveAtLeastSize(1) + it.errors[0].message shouldContainIgnoringCase "\$value is missing" + } + } + + object OmitValue + + private suspend fun executeGetAllByTagAndValueQuery( + queryName: String, + tag: String, + value: Any? + ): List = + executeGetAllByTagAndValueQuery(queryName, mapOf("tag" to tag, "value" to value)) + + private suspend fun executeGetAllByTagAndValueQuery( + queryName: String, + tag: String, + @Suppress("UNUSED_PARAMETER") value: OmitValue + ): List = executeGetAllByTagAndValueQuery(queryName, mapOf("tag" to tag)) + + private suspend fun executeGetAllByTagAndValueQuery( + queryName: String, + variables: Map + ): List { + @Serializable data class QueryData(val items: List) + val queryRef = queryRefForVariables(queryName, variables, serializer()) + val queryResult = queryRef.execute() + return queryResult.data.items + } + + private suspend fun executeInsert3Mutation( + operationName: String, + tag: String, + value1: Any?, + value2: Any?, + value3: Any?, + ): Insert3MutationDataStrings { + val mutationRef = + mutationRefForVariables( + operationName, + variables = mapOf("tag" to tag, "value1" to value1, "value2" to value2, "value3" to value3), + dataDeserializer = serializer(), + ) + return mutationRef.execute().data + } + + private suspend fun executeInsertMutation( + operationName: String, + variable: Any?, + ): TestTableKey { + val mutationRef = + mutationRefForVariable( + operationName, + variable, + dataDeserializer = serializer(), + ) + return mutationRef.execute().data.key + } + + private suspend fun executeInsertMutation( + operationName: String, + @Suppress("UNUSED_PARAMETER") variables: EmptyVariables, + ): TestTableKey { + val mutationRef = + mutationRefForVariables( + operationName, + emptyMap(), + dataDeserializer = serializer(), + ) + return mutationRef.execute().data.key + } + + private suspend fun verifyQueryResult2( + operationName: String, + key: TestTableKey, + expectedData: Any? + ) { + val queryRef = + dataConnect.query( + operationName = operationName, + variables = QueryByKeyVariables(key), + DataConnectUntypedData, + serializer(), + ) + val queryResult = queryRef.execute() + queryResult.data.asClue { + it.data.shouldNotBeNull() + it.data shouldBe mapOf("item" to mapOf("value" to expectedData)) + it.errors.shouldBeEmpty() + } + } + + @Serializable data class TestTableKey(val id: UUID) + @Serializable data class TestTableKeyString(val id: String) + + @Serializable private data class InsertMutationData(val key: TestTableKey) + + @Serializable + private data class Insert3MutationDataStrings( + val key1: TestTableKeyString, + val key2: TestTableKeyString, + val key3: TestTableKeyString + ) + + @Serializable private data class QueryByKeyVariables(val key: TestTableKey) + + private companion object { + + val normalCasePropTestConfig = + PropTestConfig(iterations = 5, edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.0)) + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AppCheckIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AppCheckIntegrationTest.kt new file mode 100644 index 00000000000..7d4a74181c9 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AppCheckIntegrationTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import app.cash.turbine.test +import com.google.firebase.appcheck.FirebaseAppCheck +import com.google.firebase.dataconnect.testutil.DataConnectBackend +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.DataConnectTestAppCheckProviderFactory +import com.google.firebase.dataconnect.testutil.InvalidInstrumentationArgumentException +import com.google.firebase.dataconnect.testutil.getInstrumentationArgument +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.randomPersonId +import com.google.firebase.dataconnect.testutil.schemas.randomPersonName +import io.grpc.Status +import io.grpc.StatusException +import io.kotest.assertions.asClue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.Assume.assumeNotNull +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test + +class AppCheckIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema by lazy { PersonSchema(dataConnectFactory) } + + private val appCheck: FirebaseAppCheck + get() = FirebaseAppCheck.getInstance(personSchema.dataConnect.app) + + private val appId: String + get() = personSchema.dataConnect.app.options.applicationId + + @Before + fun skipIfUsingEmulator() { + val backend = DataConnectBackend.fromInstrumentationArguments() + assumeTrue( + "This test cannot be run against the Data Connect emulator (backend=$backend)", + backend !is DataConnectBackend.Emulator + ) + } + + @Before + fun skipIfAppCheckNotInEnforcingMode() { + assumeTrue( + "This test must be run against a production project with App Check" + + " enabled and in enforcing mode. This requires setting up the project as documented" + + " in DataConnectTestAppCheckProvider", + isAppCheckInEnforcingMode() + ) + } + + @Test + fun queryAndMutationShouldSucceedWhenAppCheckTokenIsProvided() = runTest { + appCheck.installAppCheckProviderFactory(DataConnectTestAppCheckProviderFactory(appId)) + + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + + personSchema.createPerson(id = person1Id, name = "TestName1", age = 42).execute() + personSchema.createPerson(id = person2Id, name = "TestName2", age = 43).execute() + personSchema.createPerson(id = person3Id, name = "TestName3", age = 44).execute() + val queryResult = personSchema.getPerson(id = person2Id).execute() + + queryResult.asClue { + it.data.person shouldBe PersonSchema.GetPersonQuery.Data.Person("TestName2", 43) + } + } + + @Test + fun queryShouldFailWhenAppCheckTokenIsThePlaceholder() = runTest { + // TODO: Add an integration test where the AppCheck dependency is absent, and ensure that no + // appcheck token is sent at all. + val queryRef = personSchema.getPerson(id = randomPersonId()) + + val thrownException = shouldThrow { queryRef.execute() } + + thrownException.asClue { it.status.code shouldBe Status.UNAUTHENTICATED.code } + } + + @Test + fun mutationShouldFailWhenAppCheckTokenIsThePlaceholder() = runTest { + // TODO: Add an integration test where the AppCheck dependency is absent, and ensure that no + // appcheck token is sent at all. + val mutationRef = personSchema.createPerson(id = randomPersonId(), name = randomPersonName()) + + val thrownException = shouldThrow { mutationRef.execute() } + + thrownException.asClue { it.status.code shouldBe Status.UNAUTHENTICATED.code } + } + + @Test + fun queryShouldRetryIfAppCheckTokenIsExpired() = runTest { + val expiredToken = getInstrumentationArgument(APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG) + println("$APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG instrumentation argument: $expiredToken") + assumeNotNull( + "This test can only be run if an expired token is provided." + + " To get an expired token, set the $APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG" + + " instrumentation argument to \"collect\", which will cause this test to simply get" + + " and print an App Check token in the logcat. Then, wait until that token expires," + + " which is typically 1 hour, and re-run this test, instead setting the" + + " $APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG instrumentation argument to the token" + + " printed when \"collect\" was specified, which should now be expired" + + " (error code rqbahvqjk8)", + expiredToken + ) + + if (expiredToken == "collect") { + val appCheckProviderFactory = DataConnectTestAppCheckProviderFactory(appId) + val appCheckProvider = appCheckProviderFactory.create(firebaseAppFactory.newInstance()) + val token = appCheckProvider.getToken().await().token + println("43nyfb9epw Here is the App Check token (without the quotes): \"$token\"") + return@runTest + } + + // Install an App Check provider that will initially produce the expired token, and will fetch + // a new, valid token on subsequent requests. + val appCheckProviderFactory = + DataConnectTestAppCheckProviderFactory(appId, initialToken = expiredToken) + appCheck.installAppCheckProviderFactory(appCheckProviderFactory) + + // Make sure that the App Check doesn't refresh the expired token for us, as it races with + // the Data Connect SDKs logic to refresh the token. + appCheck.setTokenAutoRefreshEnabled(false) + + // Send an ExecuteQuery request that should be retired because the first request is sent with + // the expired token, which should fail with UNAUTHORIZED, triggering a token refresh and + // request retry. + personSchema.getPerson(id = randomPersonId()).execute() + + appCheckProviderFactory.tokens.test { + withClue("token1") { + val token = awaitItem() + token.token shouldBe expiredToken + } + withClue("token2") { + val token = awaitItem() + token.token shouldNotBe expiredToken + } + } + } + + @Test + fun mutationShouldRetryIfAppCheckTokenIsExpired() = runTest { + val expiredToken = getInstrumentationArgument(APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG) + println("$APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG instrumentation argument: $expiredToken") + assumeNotNull( + "This test can only be run if an expired token is provided." + + " To get an expired token, set the $APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG" + + " instrumentation argument to \"collect\", which will cause this test to simply get" + + " and print an App Check token in the logcat. Then, wait until that token expires," + + " which is typically 1 hour, and re-run this test, instead setting the" + + " $APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG instrumentation argument to the token" + + " printed when \"collect\" was specified, which should now be expired" + + " (error code frsdh5dpxp)", + expiredToken + ) + + if (expiredToken == "collect") { + val appCheckProviderFactory = DataConnectTestAppCheckProviderFactory(appId) + val appCheckProvider = appCheckProviderFactory.create(firebaseAppFactory.newInstance()) + val token = appCheckProvider.getToken().await().token + println("5xtk6tg4pe Here is the App Check token (without the quotes): \"$token\"") + return@runTest + } + + // Install an App Check provider that will initially produce the expired token, and will fetch + // a new, valid token on subsequent requests. + val appCheckProviderFactory = + DataConnectTestAppCheckProviderFactory(appId, initialToken = expiredToken) + appCheck.installAppCheckProviderFactory(appCheckProviderFactory) + + // Make sure that the App Check doesn't refresh the expired token for us, as it races with + // the Data Connect SDKs logic to refresh the token. + appCheck.setTokenAutoRefreshEnabled(false) + + // Send an ExecuteMutation request that should be retired because the first request is sent with + // the expired token, which should fail with UNAUTHORIZED, triggering a token refresh and + // request retry. + personSchema.createPerson(id = randomPersonId(), name = randomPersonName()).execute() + + appCheckProviderFactory.tokens.test { + withClue("token1") { + val token = awaitItem() + token.token shouldBe expiredToken + } + withClue("token2") { + val token = awaitItem() + token.token shouldNotBe expiredToken + } + } + } + + private companion object { + const val APP_CHECK_ENFORCING_INSTRUMENTATION_ARG = "DATA_CONNECT_APP_CHECK_ENFORCING" + const val APP_CHECK_EXPIRED_TOKEN_INSTRUMENTATION_ARG = "DATA_CONNECT_APP_CHECK_EXPIRED_TOKEN" + + private fun isAppCheckInEnforcingMode(): Boolean { + return when ( + val value = getInstrumentationArgument(APP_CHECK_ENFORCING_INSTRUMENTATION_ARG) + ) { + null -> false + "0" -> false + "1" -> true + else -> + throw InvalidInstrumentationArgumentException( + APP_CHECK_ENFORCING_INSTRUMENTATION_ARG, + value, + "must be either \"0\" or \"1\"" + ) + } + } + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt new file mode 100644 index 00000000000..11dabb3da4a --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.dataconnect.testutil.DataConnectBackend +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcServer +import com.google.firebase.dataconnect.testutil.newInstance +import com.google.firebase.dataconnect.testutil.operationName +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonAuthQuery +import com.google.firebase.dataconnect.testutil.schemas.randomPersonId +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.util.nextAlphanumericString +import google.firebase.dataconnect.proto.executeMutationResponse +import google.firebase.dataconnect.proto.executeQueryResponse +import io.grpc.Metadata +import io.grpc.Status +import io.grpc.StatusException +import io.kotest.assertions.asClue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.matchers.collections.shouldNotContainNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.next +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.random.Random +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AuthIntegrationTest : DataConnectIntegrationTestBase() { + + private val key = "e6w33rw36t" + + @get:Rule val inProcessDataConnectGrpcServer = InProcessDataConnectGrpcServer() + + private val personSchema by lazy { PersonSchema(dataConnectFactory) } + + private val auth: FirebaseAuth by lazy { + DataConnectBackend.fromInstrumentationArguments() + .authBackend + .getFirebaseAuth(personSchema.dataConnect.app) + } + + @Test + fun authenticatedRequestsAreSuccessful() = runTest { + signIn() + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + + personSchema.createPersonAuth(id = person1Id, name = "TestName1", age = 42).execute() + personSchema.createPersonAuth(id = person2Id, name = "TestName2", age = 43).execute() + personSchema.createPersonAuth(id = person3Id, name = "TestName3", age = 44).execute() + val queryResult = personSchema.getPersonAuth(id = person2Id).execute() + + queryResult.asClue { it.data.person shouldBe GetPersonAuthQuery.Data.Person("TestName2", 43) } + } + + @Test + fun queryFailsAfterUserSignsOut() = runTest { + signIn() + // Verify that we are signed in by executing a query, which should succeed. + personSchema.getPersonAuth(id = "foo").execute() + signOut() + + val thrownException = + shouldThrow { personSchema.getPersonAuth(id = "foo").execute() } + + thrownException.asClue { it.status.code shouldBe Status.UNAUTHENTICATED.code } + } + + @Test + fun mutationFailsAfterUserSignsOut() = runTest { + signIn() + // Verify that we are signed in by executing a mutation, which should succeed. + personSchema.createPersonAuth(id = Random.nextAlphanumericString(20), name = "foo").execute() + signOut() + + val thrownException = + shouldThrow { + personSchema + .createPersonAuth(id = Random.nextAlphanumericString(20), name = "foo") + .execute() + } + + thrownException.asClue { it.status.code shouldBe Status.UNAUTHENTICATED.code } + } + + @Test + fun queryShouldRetryOnUnauthenticated() = runTest { + signIn() + val responseData = buildStructProto { put("foo", key) } + val executeQueryResponse = executeQueryResponse { data = responseData } + val grpcServer = + inProcessDataConnectGrpcServer.newInstance( + errors = listOf(Status.UNAUTHENTICATED), + executeQueryResponse = executeQueryResponse + ) + val authTokens = CopyOnWriteArrayList() + backgroundScope.launch { + grpcServer.metadatas.map { it.get(firebaseAuthTokenHeader) }.toCollection(authTokens) + } + val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + val operationName = Arb.operationName(key).next(rs) + val queryRef = + dataConnect.query(operationName, Unit, serializer(), serializer()) + + val actualResponse = queryRef.execute() + + actualResponse.asClue { it.data shouldBe TestData(key) } + withClue("authTokens") { + authTokens.shouldNotContainNull() + authTokens.shouldHaveAtLeastSize(2) + } + } + + @Test + fun mutationShouldRetryOnUnauthenticated() = runTest { + signIn() + val responseData = buildStructProto { put("foo", key) } + val executeMutationResponse = executeMutationResponse { data = responseData } + val grpcServer = + inProcessDataConnectGrpcServer.newInstance( + errors = listOf(Status.UNAUTHENTICATED), + executeMutationResponse = executeMutationResponse + ) + val authTokens = CopyOnWriteArrayList() + backgroundScope.launch { + grpcServer.metadatas.map { it.get(firebaseAuthTokenHeader) }.toCollection(authTokens) + } + val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + val operationName = Arb.operationName(key).next(rs) + val mutationRef = + dataConnect.mutation(operationName, Unit, serializer(), serializer()) + + val actualResponse = mutationRef.execute() + + actualResponse.asClue { it.data shouldBe TestData(key) } + withClue("authTokens") { + authTokens.shouldNotContainNull() + authTokens.shouldHaveAtLeastSize(2) + } + } + + @Test + fun queryShouldOnlyRetryOnUnauthenticatedOnce() = runTest { + signIn() + val grpcServer = + inProcessDataConnectGrpcServer.newInstance( + errors = listOf(Status.UNAUTHENTICATED, Status.UNAUTHENTICATED), + ) + val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + val operationName = Arb.operationName(key).next(rs) + val queryRef = dataConnect.query(operationName, Unit, serializer(), serializer()) + + val thrownException = shouldThrow { queryRef.execute() } + + thrownException.asClue { it.status shouldBe Status.UNAUTHENTICATED } + } + + @Test + fun mutationShouldOnlyRetryOnUnauthenticatedOnce() = runTest { + signIn() + val grpcServer = + inProcessDataConnectGrpcServer.newInstance( + errors = listOf(Status.UNAUTHENTICATED, Status.UNAUTHENTICATED), + ) + val dataConnect = dataConnectFactory.newInstance(auth.app, grpcServer) + val operationName = Arb.operationName(key).next(rs) + val mutationRef = + dataConnect.mutation(operationName, Unit, serializer(), serializer()) + + val thrownException = shouldThrow { mutationRef.execute() } + + thrownException.asClue { it.status shouldBe Status.UNAUTHENTICATED } + } + + private suspend fun signIn() { + val authResult = auth.run { signInAnonymously().await() } + withClue("authResult.user returned from signInAnonymously()") { + authResult.user.shouldNotBeNull() + } + } + + private fun signOut() { + auth.run { signOut() } + } + + @Serializable data class TestData(val foo: String) + + private companion object { + private val firebaseAuthTokenHeader: Metadata.Key = + Metadata.Key.of("x-firebase-auth-token", Metadata.ASCII_STRING_MARSHALLER) + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedDataIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedDataIntegrationTest.kt new file mode 100644 index 00000000000..5eb486cfdc7 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedDataIntegrationTest.kt @@ -0,0 +1,402 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.randomId +import com.google.firebase.dataconnect.testutil.schemas.AllTypesSchema +import com.google.firebase.dataconnect.testutil.withDataDeserializer +import com.google.firebase.dataconnect.testutil.withVariables +import kotlinx.coroutines.test.* +import kotlinx.serialization.Serializable +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DataConnectUntypedDataIntegrationTest : DataConnectIntegrationTestBase() { + + private val allTypesSchema by lazy { AllTypesSchema(dataConnectFactory) } + + @Test + fun primitiveTypes() = runTest { + val id = randomId() + allTypesSchema + .createPrimitive( + AllTypesSchema.PrimitiveData( + id = id, + idFieldNullable = "eebf7592cf744871873000a03a9af43e", + intField = 42, + intFieldNullable = 43, + floatField = 99.0, + floatFieldNullable = 100.0, + booleanField = false, + booleanFieldNullable = true, + stringField = "TestStringValue", + stringFieldNullable = "TestStringNullableValue", + ) + ) + .execute() + val query = allTypesSchema.getPrimitive(id = id).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("primitive") + assertWithMessage("data.keys[primitive]") + .that(result.data.data?.get("primitive") as Map<*, *>) + .containsExactlyEntriesIn( + mapOf( + "id" to id, + "idFieldNullable" to "eebf7592cf744871873000a03a9af43e", + "intField" to 42.0, + "intFieldNullable" to 43.0, + "floatField" to 99.0, + "floatFieldNullable" to 100.0, + "booleanField" to false, + "booleanFieldNullable" to true, + "stringField" to "TestStringValue", + "stringFieldNullable" to "TestStringNullableValue", + ) + ) + } + + @Test + fun nullPrimitiveTypes() = runTest { + val id = randomId() + allTypesSchema + .createPrimitive( + AllTypesSchema.PrimitiveData( + id = id, + idFieldNullable = null, + intField = 42, + intFieldNullable = null, + floatField = 99.0, + floatFieldNullable = null, + booleanField = false, + booleanFieldNullable = null, + stringField = "TestStringValue", + stringFieldNullable = null, + ) + ) + .execute() + val query = allTypesSchema.getPrimitive(id = id).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("primitive") + assertWithMessage("data.keys[primitive]") + .that(result.data.data?.get("primitive") as Map<*, *>) + .containsExactlyEntriesIn( + mapOf( + "id" to id, + "idFieldNullable" to null, + "intField" to 42.0, + "intFieldNullable" to null, + "floatField" to 99.0, + "floatFieldNullable" to null, + "booleanField" to false, + "booleanFieldNullable" to null, + "stringField" to "TestStringValue", + "stringFieldNullable" to null, + ) + ) + } + + @Test + fun listsOfPrimitiveTypes() = runTest { + val id = randomId() + allTypesSchema + .createPrimitiveList( + AllTypesSchema.PrimitiveListData( + id = id, + idListNullable = + listOf("257e52b0c3bf4414a7fa8824b605f134", "33561fab8645464cb81889ce9a72f8bf"), + idListOfNullable = + listOf("517cb2d648f34be3bb0d8ab81e57dabc", "1ebedd8f870746f2bd1bb72c2b71b354"), + intList = listOf(42, 43, 44), + intListNullable = listOf(45, 46), + intListOfNullable = listOf(47, 48), + floatList = listOf(12.3, 45.6, 78.9), + floatListNullable = listOf(98.7, 65.4), + floatListOfNullable = listOf(100.1, 100.2), + booleanList = listOf(true, false, true, false), + booleanListNullable = listOf(false, true, false, true), + booleanListOfNullable = listOf(false, false, true, true), + stringList = listOf("xxx", "yyy", "zzz"), + stringListNullable = listOf("qqq", "rrr"), + stringListOfNullable = listOf("sss", "ttt"), + ) + ) + .execute() + val query = + allTypesSchema.getPrimitiveList(id = id).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("primitiveList") + assertWithMessage("data.keys[primitiveList]") + .that(result.data.data?.get("primitiveList") as Map<*, *>) + .containsExactlyEntriesIn( + mapOf( + "id" to id, + "idListNullable" to + listOf("257e52b0c3bf4414a7fa8824b605f134", "33561fab8645464cb81889ce9a72f8bf"), + "idListOfNullable" to + listOf("517cb2d648f34be3bb0d8ab81e57dabc", "1ebedd8f870746f2bd1bb72c2b71b354"), + "intList" to listOf(42.0, 43.0, 44.0), + "intListNullable" to listOf(45.0, 46.0), + "intListOfNullable" to listOf(47.0, 48.0), + "floatList" to listOf(12.3, 45.6, 78.9), + "floatListNullable" to listOf(98.7, 65.4), + "floatListOfNullable" to listOf(100.1, 100.2), + "booleanList" to listOf(true, false, true, false), + "booleanListNullable" to listOf(false, true, false, true), + "booleanListOfNullable" to listOf(false, false, true, true), + "stringList" to listOf("xxx", "yyy", "zzz"), + "stringListNullable" to listOf("qqq", "rrr"), + "stringListOfNullable" to listOf("sss", "ttt"), + ) + ) + } + + @Test + fun nullListsOfPrimitiveTypes() = runTest { + val id = randomId() + allTypesSchema + .createPrimitiveList( + AllTypesSchema.PrimitiveListData( + id = id, + idListNullable = null, + idListOfNullable = + listOf("1a392d5a4b424425b9ad677ac8066697", "9faab31ea1084b53be6945fc47c4f0fc"), + intList = listOf(42, 43, 44), + intListNullable = null, + intListOfNullable = listOf(47, 48), + floatList = listOf(12.3, 45.6, 78.9), + floatListNullable = null, + floatListOfNullable = listOf(100.1, 100.2), + booleanList = listOf(true, false, true, false), + booleanListNullable = null, + booleanListOfNullable = listOf(false, false, true, true), + stringList = listOf("xxx", "yyy", "zzz"), + stringListNullable = null, + stringListOfNullable = listOf("sss", "ttt"), + ) + ) + .execute() + val query = + allTypesSchema.getPrimitiveList(id = id).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("primitiveList") + assertWithMessage("data.keys[primitiveList]") + .that(result.data.data?.get("primitiveList") as Map<*, *>) + .containsExactlyEntriesIn( + mapOf( + "id" to id, + "idListNullable" to null, + "idListOfNullable" to + listOf("1a392d5a4b424425b9ad677ac8066697", "9faab31ea1084b53be6945fc47c4f0fc"), + "intList" to listOf(42.0, 43.0, 44.0), + "intListNullable" to null, + "intListOfNullable" to listOf(47.0, 48.0), + "floatList" to listOf(12.3, 45.6, 78.9), + "floatListNullable" to null, + "floatListOfNullable" to listOf(100.1, 100.2), + "booleanList" to listOf(true, false, true, false), + "booleanListNullable" to null, + "booleanListOfNullable" to listOf(false, false, true, true), + "stringList" to listOf("xxx", "yyy", "zzz"), + "stringListNullable" to null, + "stringListOfNullable" to listOf("sss", "ttt"), + ) + ) + } + + @Test + fun nestedStructs() = runTest { + val farmer1Id = randomId() + val farmer2Id = randomId() + val farmer3Id = randomId() + val farmer4Id = randomId() + val farmId = randomId() + val animal1Id = randomId() + val animal2Id = randomId() + allTypesSchema.createFarmer(id = farmer1Id, name = "Farmer1Name", parentId = null).execute() + allTypesSchema + .createFarmer(id = farmer2Id, name = "Farmer2Name", parentId = farmer1Id) + .execute() + allTypesSchema + .createFarmer(id = farmer3Id, name = "Farmer3Name", parentId = farmer2Id) + .execute() + allTypesSchema + .createFarmer(id = farmer4Id, name = "Farmer4Name", parentId = farmer3Id) + .execute() + allTypesSchema.createFarm(id = farmId, name = "TestFarm", farmerId = farmer4Id).execute() + allTypesSchema + .createAnimal( + id = animal1Id, + farmId = farmId, + name = "Animal1Name", + species = "Animal1Species", + age = 1 + ) + .execute() + allTypesSchema + .createAnimal( + id = animal2Id, + farmId = farmId, + name = "Animal2Name", + species = "Animal2Species", + age = null + ) + .execute() + val query = allTypesSchema.getFarm(id = farmId).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("farm") + val farm = + result.data.data!!.get("farm").let { + val farm = it as? Map<*, *> + assertWithMessage("farm: $it").that(farm).isNotNull() + farm!! + } + assertWithMessage("farm.keys") + .that(farm.keys) + .containsExactly("id", "name", "farmer", "animals") + assertWithMessage("farm[id]").that(farm["id"]).isEqualTo(farmId) + assertWithMessage("farm[name]").that(farm["name"]).isEqualTo("TestFarm") + val animals = + farm["animals"].let { + val animals = it as? List<*> + assertWithMessage("animals: $it").that(animals).isNotNull() + animals!! + } + assertWithMessage("farm[animals]") + .that(animals) + .containsExactly( + mapOf( + "id" to animal1Id, + "name" to "Animal1Name", + "species" to "Animal1Species", + "age" to 1.0 + ), + mapOf( + "id" to animal2Id, + "name" to "Animal2Name", + "species" to "Animal2Species", + "age" to null + ), + ) + val farmer = + farm["farmer"].let { + val farmer = it as? Map<*, *> + assertWithMessage("farmer: $it").that(farmer).isNotNull() + farmer!! + } + assertWithMessage("farmer.keys").that(farmer.keys).containsExactly("id", "name", "parent") + assertWithMessage("farmer[id]").that(farmer["id"]).isEqualTo(farmer4Id) + assertWithMessage("farmer[name]").that(farmer["name"]).isEqualTo("Farmer4Name") + val parent = + farmer["parent"].let { + val parent = it as? Map<*, *> + assertWithMessage("parent: $it").that(parent).isNotNull() + parent!! + } + assertWithMessage("parent.keys").that(parent.keys).containsExactly("id", "name", "parentId") + assertWithMessage("parent[id]").that(parent["id"]).isEqualTo(farmer3Id) + assertWithMessage("parent[name]").that(parent["name"]).isEqualTo("Farmer3Name") + assertWithMessage("parent[parentId]").that(parent["parentId"]).isEqualTo(farmer2Id) + } + + @Test + fun nestedNullStructs() = runTest { + val farmerId = randomId() + val farmId = randomId() + allTypesSchema.createFarmer(id = farmerId, name = "FarmerName", parentId = null).execute() + allTypesSchema.createFarm(id = farmId, name = "TestFarm", farmerId = farmerId).execute() + val query = allTypesSchema.getFarm(id = farmId).withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("errors").that(result.data.errors).isEmpty() + assertWithMessage("data").that(result.data.data).isNotNull() + assertWithMessage("data.keys").that(result.data.data?.keys).containsExactly("farm") + val farm = + result.data.data!!.get("farm").let { + val farm = it as? Map<*, *> + assertWithMessage("farm: $it").that(farm).isNotNull() + farm!! + } + assertWithMessage("farm.keys") + .that(farm.keys) + .containsExactly("id", "name", "farmer", "animals") + val farmer = + farm["farmer"].let { + val farmer = it as? Map<*, *> + assertWithMessage("farmer: $it").that(farmer).isNotNull() + farmer!! + } + assertWithMessage("farmer.keys").that(farmer.keys).containsExactly("id", "name", "parent") + assertWithMessage("farmer[id]").that(farmer["id"]).isEqualTo(farmerId) + assertWithMessage("farmer[name]").that(farmer["name"]).isEqualTo("FarmerName") + assertWithMessage("farmer[parent]").that(farmer["parent"]).isNull() + } + + @Test + fun queryErrorsReturnedByServerArePutInTheErrorsListInsteadOfThrowingAnException() = runTest { + @Serializable data class BogusVariables(val foo: String) + val query = + allTypesSchema + .getPrimitive("foo") + .withVariables(BogusVariables(foo = "bar")) + .withDataDeserializer(DataConnectUntypedData) + + val result = query.execute() + + assertWithMessage("result.data.data").that(result.data.data).isNull() + assertWithMessage("result.data.errors").that(result.data.errors).isNotEmpty() + } + + @Test + fun mutationErrorsReturnedByServerArePutInTheErrorsListInsteadOfThrowingAnException() = runTest { + @Serializable data class BogusVariables(val foo: String) + val mutation = + allTypesSchema + .createAnimal("", "", "", "", 42) + .withVariables(BogusVariables(foo = "bar")) + .withDataDeserializer(DataConnectUntypedData) + + val result = mutation.execute() + + assertWithMessage("result.data.data").that(result.data.data).isNull() + assertWithMessage("result.data.errors").that(result.data.errors).isNotEmpty() + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariablesIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariablesIntegrationTest.kt new file mode 100644 index 00000000000..0b20c36f067 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariablesIntegrationTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPeopleWithHardcodedNameQuery.hardcodedPeople +import com.google.firebase.dataconnect.testutil.schemas.randomPersonId +import com.google.firebase.dataconnect.testutil.withVariables +import kotlinx.coroutines.test.* +import org.junit.Test + +class DataConnectUntypedVariablesIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema by lazy { PersonSchema(dataConnectFactory) } + + @Test + fun emptyMapWorksWithQuery() = runTest { + personSchema.createPeopleWithHardcodedName.execute() + val query = personSchema.getPeopleWithHardcodedName.withVariables(DataConnectUntypedVariables()) + + val result = query.execute() + + assertThat(result.ref).isSameInstanceAs(query) + assertThat(result.data.people).containsExactlyElementsIn(hardcodedPeople) + } + + @Test + fun nonEmptyMapWorksWithQuery() = runTest { + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + personSchema.createPerson(id = person1Id, name = "Person1Name", age = 42).execute() + personSchema.createPerson(id = person2Id, name = "Person2Name", age = 43).execute() + personSchema.createPerson(id = person3Id, name = "Person3Name", age = null).execute() + val query = + personSchema.getPerson("").withVariables(DataConnectUntypedVariables("id" to person2Id)) + + val result = query.execute() + + assertThat(result.ref).isSameInstanceAs(query) + assertThat(result.data) + .isEqualTo( + PersonSchema.GetPersonQuery.Data( + person = PersonSchema.GetPersonQuery.Data.Person(name = "Person2Name", age = 43) + ) + ) + } + + @Test + fun emptyMapWorksWithMutation() = runTest { + val mutation = personSchema.createDefaultPerson.withVariables(DataConnectUntypedVariables()) + + val mutationResult = mutation.execute() + + val personId = mutationResult.data.person_insert.id + val result = personSchema.getPerson(id = personId).execute() + assertThat(result.data) + .isEqualTo( + PersonSchema.GetPersonQuery.Data( + PersonSchema.GetPersonQuery.Data.Person(name = "DefaultName", age = 42) + ) + ) + } + + @Test + fun nonEmptyMapWorksWithMutation() = runTest { + val personId = randomPersonId() + + val mutation = + personSchema + .createPerson("", "", null) + .withVariables( + variables = + DataConnectUntypedVariables( + "id" to personId, + "name" to "TestPersonName", + "age" to 42.0 + ), + serializer = DataConnectUntypedVariables + ) + + mutation.execute() + + val result = personSchema.getPerson(id = personId).execute() + assertThat(result.data) + .isEqualTo( + PersonSchema.GetPersonQuery.Data( + PersonSchema.GetPersonQuery.Data.Person(name = "TestPersonName", age = 42) + ) + ) + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/FirebaseDataConnectIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/FirebaseDataConnectIntegrationTest.kt new file mode 100644 index 00000000000..58bbfca923c --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/FirebaseDataConnectIntegrationTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcServer +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import com.google.firebase.dataconnect.testutil.newInstance +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import kotlin.concurrent.withLock +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.serializer +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FirebaseDataConnectIntegrationTest : DataConnectIntegrationTestBase() { + + @get:Rule val inProcessDataConnectGrpcServer = InProcessDataConnectGrpcServer() + + @Test + fun getInstance_without_specifying_an_app_should_use_the_default_app() { + val instance1 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + val instance2 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG2) + + // Validate the assumption that different location and serviceId yield distinct instances. + assertThat(instance1).isNotSameInstanceAs(instance2) + + val instance1DefaultApp = FirebaseDataConnect.getInstance(SAMPLE_CONNECTOR_CONFIG1) + val instance2DefaultApp = FirebaseDataConnect.getInstance(SAMPLE_CONNECTOR_CONFIG2) + + assertThat(instance1DefaultApp).isSameInstanceAs(instance1) + assertThat(instance2DefaultApp).isSameInstanceAs(instance2) + } + + @Test + fun getInstance_with_default_app_should_return_non_null() { + val instance = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + assertThat(instance).isNotNull() + } + + @Test + fun getInstance_with_default_app_should_return_the_same_instance_every_time() { + val instance1 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + val instance2 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + assertThat(instance1).isSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_new_instance_after_terminate() { + val instance1 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + instance1.close() + val instance2 = FirebaseDataConnect.getInstance(Firebase.app, SAMPLE_CONNECTOR_CONFIG1) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_distinct_instances_for_distinct_apps() { + val nonDefaultApp1 = firebaseAppFactory.newInstance() + val nonDefaultApp2 = firebaseAppFactory.newInstance() + val instance1 = FirebaseDataConnect.getInstance(nonDefaultApp1, SAMPLE_CONNECTOR_CONFIG1) + val instance2 = FirebaseDataConnect.getInstance(nonDefaultApp2, SAMPLE_CONNECTOR_CONFIG1) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_distinct_instances_for_distinct_configs() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val config1 = SAMPLE_CONNECTOR_CONFIG1.copy(serviceId = "foo") + val config2 = config1.copy(serviceId = "bar") + val instance1 = FirebaseDataConnect.getInstance(nonDefaultApp, config1) + val instance2 = FirebaseDataConnect.getInstance(nonDefaultApp, config2) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_distinct_instances_for_distinct_locations() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val config1 = SAMPLE_CONNECTOR_CONFIG1.copy(location = "foo") + val config2 = config1.copy(location = "bar") + val instance1 = FirebaseDataConnect.getInstance(nonDefaultApp, config1) + val instance2 = FirebaseDataConnect.getInstance(nonDefaultApp, config2) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_distinct_instances_for_distinct_connectors() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val config1 = SAMPLE_CONNECTOR_CONFIG1.copy(connector = "foo") + val config2 = config1.copy(connector = "bar") + val instance1 = FirebaseDataConnect.getInstance(nonDefaultApp, config1) + val instance2 = FirebaseDataConnect.getInstance(nonDefaultApp, config2) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_a_new_instance_after_the_instance_is_terminated() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1A = FirebaseDataConnect.getInstance(nonDefaultApp, SAMPLE_CONNECTOR_CONFIG1) + val instance2A = FirebaseDataConnect.getInstance(nonDefaultApp, SAMPLE_CONNECTOR_CONFIG2) + assertThat(instance1A).isNotSameInstanceAs(instance2A) + + instance1A.close() + val instance1B = FirebaseDataConnect.getInstance(nonDefaultApp, SAMPLE_CONNECTOR_CONFIG1) + assertThat(instance1A).isNotSameInstanceAs(instance1B) + assertThat(instance1A).isNotSameInstanceAs(instance2A) + + instance2A.close() + val instance2B = FirebaseDataConnect.getInstance(nonDefaultApp, SAMPLE_CONNECTOR_CONFIG2) + assertThat(instance2A).isNotSameInstanceAs(instance2B) + assertThat(instance2A).isNotSameInstanceAs(instance1A) + assertThat(instance2A).isNotSameInstanceAs(instance1B) + } + + @Test + fun getInstance_should_return_the_cached_instance_if_settings_compare_equal() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName") + ) + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName") + ) + assertThat(instance1).isSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_throw_if_settings_compare_unequal_to_settings_of_cached_instance() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName1") + ) + + assertThrows(IllegalArgumentException::class.java) { + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName2") + ) + } + + assertThrows(IllegalArgumentException::class.java) { + FirebaseDataConnect.getInstance(nonDefaultApp, SAMPLE_CONNECTOR_CONFIG1) + } + + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName1") + ) + assertThat(instance1).isSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_allow_different_settings_after_first_instance_is_closed() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName") + ) + instance1.close() + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName2") + ) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_new_instance_if_settings_and_app_are_both_different() { + val nonDefaultApp1 = firebaseAppFactory.newInstance() + val nonDefaultApp2 = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp1, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName1") + ) + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp2, + SAMPLE_CONNECTOR_CONFIG1, + DataConnectSettings(host = "TestHostName2") + ) + assertThat(instance1).isNotSameInstanceAs(instance2) + } + + @Test + fun getInstance_should_return_new_instance_if_settings_and_config_are_both_different() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1.copy(serviceId = "foo"), + DataConnectSettings(host = "TestHostName1") + ) + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1.copy(serviceId = "bar"), + DataConnectSettings(host = "TestHostName2") + ) + + assertThat(instance1).isNotSameInstanceAs(instance2) + assertThat(instance1.settings).isEqualTo(DataConnectSettings(host = "TestHostName1")) + assertThat(instance2.settings).isEqualTo(DataConnectSettings(host = "TestHostName2")) + } + + @Test + fun getInstance_should_return_new_instance_if_settings_and_location_are_both_different() { + val nonDefaultApp = firebaseAppFactory.newInstance() + val instance1 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1.copy(location = "foo"), + DataConnectSettings(host = "TestHostName1") + ) + val instance2 = + FirebaseDataConnect.getInstance( + nonDefaultApp, + SAMPLE_CONNECTOR_CONFIG1.copy(location = "bar"), + DataConnectSettings(host = "TestHostName2") + ) + + assertThat(instance1).isNotSameInstanceAs(instance2) + assertThat(instance1.settings).isEqualTo(DataConnectSettings(host = "TestHostName1")) + assertThat(instance2.settings).isEqualTo(DataConnectSettings(host = "TestHostName2")) + } + + @Test + fun getInstance_should_be_thread_safe() { + val apps = + mutableListOf().run { + for (i in 0..4) { + add(firebaseAppFactory.newInstance()) + } + toList() + } + + val createdInstancesByThreadIdLock = ReentrantLock() + val createdInstancesByThreadId = mutableMapOf>() + val numThreads = 8 + + val threads = buildList { + val readyCountDown = AtomicInteger(numThreads) + repeat(numThreads) { i -> + add( + thread { + readyCountDown.decrementAndGet() + while (readyCountDown.get() > 0) { + /* spin */ + } + val instances = buildList { + for (app in apps) { + add(FirebaseDataConnect.getInstance(app, SAMPLE_CONNECTOR_CONFIG1)) + add(FirebaseDataConnect.getInstance(app, SAMPLE_CONNECTOR_CONFIG2)) + add(FirebaseDataConnect.getInstance(app, SAMPLE_CONNECTOR_CONFIG3)) + } + } + createdInstancesByThreadIdLock.withLock { createdInstancesByThreadId[i] = instances } + } + ) + } + } + + threads.forEach { it.join() } + + // Verify that each thread reported its result. + assertThat(createdInstancesByThreadId.size).isEqualTo(8) + + // Choose an arbitrary list of created instances from one of the threads, and use it as the + // "expected" value for all other threads. + val expectedInstances = createdInstancesByThreadId.values.toList()[0] + assertThat(expectedInstances.size).isEqualTo(15) + + createdInstancesByThreadId.entries.forEach { (threadId, createdInstances) -> + assertWithMessage("instances created by threadId=${threadId}") + .that(createdInstances) + .containsExactlyElementsIn(expectedInstances) + .inOrder() + } + } + + @Test + fun toString_should_return_a_string_that_contains_the_required_information() { + val app = firebaseAppFactory.newInstance() + val instance = + FirebaseDataConnect.getInstance( + app = app, + ConnectorConfig( + connector = "TestConnector", + location = "TestLocation", + serviceId = "TestServiceId", + ) + ) + + val toStringResult = instance.toString() + + assertThat(toStringResult).containsWithNonAdjacentText("app=${app.name}") + assertThat(toStringResult).containsWithNonAdjacentText("projectId=${app.options.projectId}") + assertThat(toStringResult).containsWithNonAdjacentText("connector=TestConnector") + assertThat(toStringResult).containsWithNonAdjacentText("location=TestLocation") + assertThat(toStringResult).containsWithNonAdjacentText("serviceId=TestServiceId") + } + + @Test + fun useEmulator_should_set_the_emulator_host() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val app = firebaseAppFactory.newInstance() + val settings = DataConnectSettings(host = "hosty63pw33994") + val dataConnect = FirebaseDataConnect.getInstance(app, testConnectorConfig, settings) + dataConnectFactory.adoptInstance(dataConnect) + + dataConnect.useEmulator(host = "127.0.0.1", port = grpcServer.server.port) + + // Verify that we can successfully execute a query; if the emulator settings did _not_ get used + // then the query execution will fail with an exception, which will fail this test case. + dataConnect.query("qryzvfy95awha", Unit, DataConnectUntypedData, serializer()).execute() + } + + @Test + fun useEmulator_should_throw_if_invoked_too_late() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + + dataConnect.query("qrymgbqrc2hj9", Unit, DataConnectUntypedData, serializer()).execute() + + val exception = assertThrows(IllegalStateException::class.java) { dataConnect.useEmulator() } + assertThat(exception).hasMessageThat().ignoringCase().contains("already been initialized") + } +} + +private val SAMPLE_SERVICE_ID1 = "SampleServiceId1" +private val SAMPLE_LOCATION1 = "SampleLocation1" +private val SAMPLE_CONNECTOR1 = "SampleConnector1" +private val SAMPLE_CONNECTOR_CONFIG1 = + ConnectorConfig( + connector = SAMPLE_CONNECTOR1, + location = SAMPLE_LOCATION1, + serviceId = SAMPLE_SERVICE_ID1, + ) + +private val SAMPLE_SERVICE_ID2 = "SampleServiceId2" +private val SAMPLE_LOCATION2 = "SampleLocation2" +private val SAMPLE_CONNECTOR2 = "SampleConnector2" +private val SAMPLE_CONNECTOR_CONFIG2 = + ConnectorConfig( + connector = SAMPLE_CONNECTOR2, + location = SAMPLE_LOCATION2, + serviceId = SAMPLE_SERVICE_ID2, + ) + +private val SAMPLE_SERVICE_ID3 = "SampleServiceId3" +private val SAMPLE_LOCATION3 = "SampleLocation3" +private val SAMPLE_CONNECTOR3 = "SampleConnector3" +private val SAMPLE_CONNECTOR_CONFIG3 = + ConnectorConfig( + connector = SAMPLE_CONNECTOR3, + location = SAMPLE_LOCATION3, + serviceId = SAMPLE_SERVICE_ID3, + ) diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt new file mode 100644 index 00000000000..7a1f80d10ba --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt @@ -0,0 +1,410 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.firebase.appcheck.AppCheckProvider +import com.google.firebase.appcheck.AppCheckProviderFactory +import com.google.firebase.appcheck.FirebaseAppCheck +import com.google.firebase.dataconnect.generated.GeneratedConnector +import com.google.firebase.dataconnect.generated.GeneratedMutation +import com.google.firebase.dataconnect.generated.GeneratedQuery +import com.google.firebase.dataconnect.testutil.DataConnectBackend +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.DataConnectTestAppCheckToken +import com.google.firebase.dataconnect.testutil.FirebaseAuthBackend +import com.google.firebase.dataconnect.testutil.InProcessDataConnectGrpcServer +import com.google.firebase.dataconnect.testutil.getFirebaseAppIdFromStrings +import com.google.firebase.dataconnect.testutil.newInstance +import com.google.firebase.dataconnect.util.SuspendingLazy +import io.grpc.Metadata +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import java.util.Date +import kotlin.time.Duration.Companion.hours +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GrpcMetadataIntegrationTest : DataConnectIntegrationTestBase() { + + @get:Rule val inProcessDataConnectGrpcServer = InProcessDataConnectGrpcServer() + + private val authBackend: SuspendingLazy = SuspendingLazy { + DataConnectBackend.fromInstrumentationArguments().authBackend + } + + @Test + fun executeQueryShouldSendExpectedGrpcMetadataNotFromGeneratedSdk() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qrysp5xs5qxy8", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + queryRef.execute() + + verifyMetadata(metadatasJob, dataConnect, isFromGeneratedSdk = false) + } + + @Test + fun executeQueryShouldSendExpectedGrpcMetadataFromGeneratedSdk() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val generatedConnector = TestGeneratedConnector(dataConnect) + val generatedQuery = + TestGeneratedQuery( + generatedConnector, + "qry2peects97z", + serializer(), + serializer(), + ) + val queryRef = generatedQuery.ref(Unit) + val metadatasJob = async { grpcServer.metadatas.first() } + + queryRef.execute() + + verifyMetadata(metadatasJob, dataConnect, isFromGeneratedSdk = true) + } + + @Test + fun executeMutationShouldSendExpectedGrpcMetadataNotFromGeneratedSdk() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutxasxstejj9", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + mutationRef.execute() + + verifyMetadata(metadatasJob, dataConnect, isFromGeneratedSdk = false) + } + + @Test + fun executeMutationShouldSendExpectedGrpcMetadataFromGeneratedSdk() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val generatedConnector = TestGeneratedConnector(dataConnect) + val generatedMutation = + TestGeneratedMutation( + generatedConnector, + "mutd6tmz8db4h", + serializer(), + serializer(), + ) + val mutationRef = generatedMutation.ref(Unit) + val metadatasJob = async { grpcServer.metadatas.first() } + + mutationRef.execute() + + verifyMetadata(metadatasJob, dataConnect, isFromGeneratedSdk = true) + } + + @Test + fun executeQueryShouldNotSendAuthMetadataWhenNotLoggedIn() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qryfyk7yfppfe", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + queryRef.execute() + + verifyMetadataDoesNotContain(metadatasJob, firebaseAuthTokenHeader) + } + + @Test + fun executeMutationShouldNotSendAuthMetadataWhenNotLoggedIn() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutckjpte9v9j", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + mutationRef.execute() + + verifyMetadataDoesNotContain(metadatasJob, firebaseAuthTokenHeader) + } + + @Test + fun executeQueryShouldSendAuthMetadataWhenLoggedIn() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + firebaseAuthSignIn(dataConnect) + + queryRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAuthTokenHeader) + } + + @Test + fun executeMutationShouldSendAuthMetadataWhenLoggedIn() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutayn7as5k7d", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + firebaseAuthSignIn(dataConnect) + + mutationRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAuthTokenHeader) + } + + @Test + fun executeQueryShouldNotSendAuthMetadataAfterLogout() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) + val metadatasJob1 = async { grpcServer.metadatas.first() } + val metadatasJob2 = async { grpcServer.metadatas.take(2).last() } + firebaseAuthSignIn(dataConnect) + queryRef.execute() + verifyMetadataContains(metadatasJob1, firebaseAuthTokenHeader) + firebaseAuthSignOut(dataConnect) + + queryRef.execute() + + verifyMetadataDoesNotContain(metadatasJob2, firebaseAuthTokenHeader) + } + + @Test + fun executeMutationShouldNotSendAuthMetadataAfterLogout() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutvw945ag3vv", Unit, serializer(), serializer()) + val metadatasJob1 = async { grpcServer.metadatas.first() } + val metadatasJob2 = async { grpcServer.metadatas.take(2).last() } + firebaseAuthSignIn(dataConnect) + mutationRef.execute() + verifyMetadataContains(metadatasJob1, firebaseAuthTokenHeader) + firebaseAuthSignOut(dataConnect) + + mutationRef.execute() + + verifyMetadataDoesNotContain(metadatasJob2, firebaseAuthTokenHeader) + } + + @Test + fun executeQueryShouldSendPlaceholderAppCheckMetadataWhenAppCheckIsNotEnabled() = runTest { + // TODO: Add an integration test where the AppCheck dependency is absent, and ensure that no + // appcheck token is sent at all. + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qrybbeekpkkck", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + queryRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAppCheckTokenHeader, PLACEHOLDER_APP_CHECK_TOKEN) + } + + @Test + fun executeMutationShouldSendPlaceholderAppCheckMetadataWhenAppCheckIsNotEnabled() = runTest { + // TODO: Add an integration test where the AppCheck dependency is absent, and ensure that no + // appcheck token is sent at all. + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutbs7hhxk39c", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + + mutationRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAppCheckTokenHeader, PLACEHOLDER_APP_CHECK_TOKEN) + } + + @Test + fun executeQueryShouldSendAppCheckMetadataWhenAppCheckIsEnabled() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val queryRef = dataConnect.query("qryyarwrxe2fv", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + val appCheck = FirebaseAppCheck.getInstance(dataConnect.app) + appCheck.installAppCheckProviderFactory(appCheckProviderFactoryForToken("7gwvj8c4xy")) + + queryRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAppCheckTokenHeader, "7gwvj8c4xy") + } + + @Test + fun executeMutationShouldSendAppCheckMetadataWhenAppCheckIsEnabled() = runTest { + val grpcServer = inProcessDataConnectGrpcServer.newInstance() + val dataConnect = dataConnectFactory.newInstance(grpcServer) + val mutationRef = + dataConnect.mutation("mutz4hzqzpgb4", Unit, serializer(), serializer()) + val metadatasJob = async { grpcServer.metadatas.first() } + val appCheck = FirebaseAppCheck.getInstance(dataConnect.app) + appCheck.installAppCheckProviderFactory(appCheckProviderFactoryForToken("2zbqew6qg7")) + + mutationRef.execute() + + verifyMetadataContains(metadatasJob, firebaseAppCheckTokenHeader, "2zbqew6qg7") + } + + private suspend fun verifyMetadataContains( + job: Deferred, + key: Metadata.Key, + expectedValue: String? = null + ) { + val metadata = withClue("waiting for metadata to be reported") { job.await() } + metadata.asClue { + val actualValue = metadata.get(key) + if (expectedValue === null) { + actualValue.shouldNotBeNull() + } else { + actualValue shouldBe expectedValue + } + } + } + + private suspend fun verifyMetadataDoesNotContain( + job: Deferred, + key: Metadata.Key + ) { + val metadata = withClue("waiting for metadata to be reported") { job.await() } + metadata.asClue { metadata.get(key).shouldBeNull() } + } + + private suspend fun verifyMetadata( + job: Deferred, + dataConnect: FirebaseDataConnect, + isFromGeneratedSdk: Boolean + ) { + val metadata = withClue("waiting for metadata to be reported") { job.await() } + val expectedAppId = getFirebaseAppIdFromStrings() + + metadata.asClue { + metadata.keys().shouldContainAll(googRequestParamsHeader.name(), googApiClientHeader.name()) + assertSoftly { + // Do not verify "x-firebase-auth-token" here since that header is effectively tested by + // AuthIntegrationTest + metadata.get(googRequestParamsHeader) shouldBe + "location=${dataConnect.config.location}&frontend=data" + metadata.get(googApiClientHeader) shouldBe expectedGoogApiClientHeader(isFromGeneratedSdk) + metadata.get(gmpAppIdHeader) shouldBe expectedAppId + } + } + } + + private suspend fun firebaseAuthSignIn(dataConnect: FirebaseDataConnect) { + withClue("FirebaseAuth.signInAnonymously()") { + val firebaseAuth = authBackend.get().getFirebaseAuth(dataConnect.app) + firebaseAuth.signInAnonymously().await() + } + } + + private suspend fun firebaseAuthSignOut(dataConnect: FirebaseDataConnect) { + withClue("FirebaseAuth.signOut()") { + val firebaseAuth = authBackend.get().getFirebaseAuth(dataConnect.app) + firebaseAuth.signOut() + } + } + + class TestGeneratedConnector(override val dataConnect: FirebaseDataConnect) : GeneratedConnector { + override fun equals(other: Any?) = other === this + override fun hashCode() = System.identityHashCode(this) + override fun toString() = "TestGeneratedConnector" + } + + class TestGeneratedQuery( + override val connector: TestGeneratedConnector, + override val operationName: String, + override val dataDeserializer: DeserializationStrategy, + override val variablesSerializer: SerializationStrategy + ) : GeneratedQuery { + override fun toString(): String = "TestGeneratedQuery" + } + + class TestGeneratedMutation( + override val connector: TestGeneratedConnector, + override val operationName: String, + override val dataDeserializer: DeserializationStrategy, + override val variablesSerializer: SerializationStrategy + ) : GeneratedMutation { + override fun toString(): String = "TestGeneratedMutation" + } + + private companion object { + const val PLACEHOLDER_APP_CHECK_TOKEN = "eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==" + + val firebaseAuthTokenHeader: Metadata.Key = + Metadata.Key.of("x-firebase-auth-token", Metadata.ASCII_STRING_MARSHALLER) + + val firebaseAppCheckTokenHeader: Metadata.Key = + Metadata.Key.of("x-firebase-appcheck", Metadata.ASCII_STRING_MARSHALLER) + + val googRequestParamsHeader: Metadata.Key = + Metadata.Key.of("x-goog-request-params", Metadata.ASCII_STRING_MARSHALLER) + + val googApiClientHeader: Metadata.Key = + Metadata.Key.of("x-goog-api-client", Metadata.ASCII_STRING_MARSHALLER) + + private val gmpAppIdHeader: Metadata.Key = + Metadata.Key.of("x-firebase-gmpid", Metadata.ASCII_STRING_MARSHALLER) + + fun expectedGoogApiClientHeader(isFromGeneratedSdk: Boolean) = buildString { + append("gl-kotlin/${KotlinVersion.CURRENT}") + append(' ') + append("gl-android/${Build.VERSION.SDK_INT}") + append(' ') + append("fire/${BuildConfig.VERSION_NAME}") + append(' ') + append("grpc/") + if (isFromGeneratedSdk) { + append(' ') + append("kotlin/gen") + } + } + + fun appCheckProviderFactoryForToken(token: String): AppCheckProviderFactory = + mockk(relaxed = true) { + every { create(any()) } returns + mockk(relaxed = true) { + every { getToken() } returns + Tasks.forResult( + DataConnectTestAppCheckToken( + token = token, + expireTimeMillis = Date().time + 1.hours.inWholeMilliseconds + ) + ) + } + } + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QueryRefIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QueryRefIntegrationTest.kt new file mode 100644 index 00000000000..a6ee023b4da --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QueryRefIntegrationTest.kt @@ -0,0 +1,376 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.SuspendingCountDownLatch +import com.google.firebase.dataconnect.testutil.randomId +import com.google.firebase.dataconnect.testutil.schemas.AllTypesSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.randomAnimalId +import com.google.firebase.dataconnect.testutil.schemas.randomFarmId +import com.google.firebase.dataconnect.testutil.schemas.randomFarmerId +import com.google.firebase.dataconnect.testutil.schemas.randomPersonId +import com.google.firebase.dataconnect.testutil.schemas.randomPersonName +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.Test + +class QueryRefIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema by lazy { PersonSchema(dataConnectFactory) } + private val allTypesSchema by lazy { AllTypesSchema(dataConnectFactory) } + + @Test + fun executeWithASingleResultReturnsTheCorrectResult() = runTest { + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + personSchema.createPerson(id = person1Id, name = "TestName1", age = 42).execute() + personSchema.createPerson(id = person2Id, name = "TestName2", age = 43).execute() + personSchema.createPerson(id = person3Id, name = "TestName3", age = 44).execute() + + val result = personSchema.getPerson(id = person2Id).execute() + + assertThat(result.data.person?.name).isEqualTo("TestName2") + assertThat(result.data.person?.age).isEqualTo(43) + } + + @Test + fun executeWithASingleResultReturnsTheUpdatedResult() = runTest { + val personId = randomPersonId() + personSchema.createPerson(id = personId, name = "TestName", age = 42).execute() + personSchema.updatePerson(id = personId, name = "NewTestName", age = 99).execute() + + val result = personSchema.getPerson(id = personId).execute() + + assertThat(result.data.person?.name).isEqualTo("NewTestName") + assertThat(result.data.person?.age).isEqualTo(99) + } + + @Test + fun executeWithASingleResultReturnsNullIfNotFound() = runTest { + val personId = randomPersonId() + personSchema.deletePerson(personId) + + val result = personSchema.getPerson(id = personId).execute() + + assertThat(result.data.person).isNull() + } + + @Test + fun executeWithAListResultReturnsAllResults() = runTest { + val personName = randomPersonName() + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + personSchema.createPerson(id = person1Id, name = personName, age = 42).execute() + personSchema.createPerson(id = person2Id, name = personName, age = 43).execute() + personSchema.createPerson(id = person3Id, name = personName, age = 44).execute() + + val result = personSchema.getPeopleByName(personName).execute() + + assertThat(result.data.people) + .containsExactly( + PersonSchema.GetPeopleByNameQuery.Data.Person(id = person1Id, age = 42), + PersonSchema.GetPeopleByNameQuery.Data.Person(id = person2Id, age = 43), + PersonSchema.GetPeopleByNameQuery.Data.Person(id = person3Id, age = 44), + ) + } + + @Test + fun executeWithAllPrimitiveGraphQLTypesInDataNoneNull() = runTest { + val id = randomId() + allTypesSchema + .createPrimitive( + AllTypesSchema.PrimitiveData( + id = id, + idFieldNullable = "e03b3062bf604428956a17c0bc444691", + intField = 42, + intFieldNullable = 43, + floatField = 123.45, + floatFieldNullable = 678.91, + booleanField = true, + booleanFieldNullable = false, + stringField = "TestString", + stringFieldNullable = "TestNullableString" + ) + ) + .execute() + + val result = allTypesSchema.getPrimitive(id = id).execute() + + val primitive = result.data.primitive ?: error("result.data.primitive is null") + assertThat(primitive.id).isEqualTo(id) + assertThat(primitive.idFieldNullable).isEqualTo("e03b3062bf604428956a17c0bc444691") + assertThat(primitive.intField).isEqualTo(42) + assertThat(primitive.intFieldNullable).isEqualTo(43) + assertThat(primitive.floatField).isEqualTo(123.45) + assertThat(primitive.floatFieldNullable).isEqualTo(678.91) + assertThat(primitive.booleanField).isEqualTo(true) + assertThat(primitive.booleanFieldNullable).isEqualTo(false) + assertThat(primitive.stringField).isEqualTo("TestString") + assertThat(primitive.stringFieldNullable).isEqualTo("TestNullableString") + } + + @Test + fun executeWithAllPrimitiveGraphQLTypesInDataNullablesAreNull() = runTest { + val id = randomId() + allTypesSchema + .createPrimitive( + AllTypesSchema.PrimitiveData( + id = id, + idFieldNullable = null, + intField = 42, + intFieldNullable = null, + floatField = 123.45, + floatFieldNullable = null, + booleanField = true, + booleanFieldNullable = null, + stringField = "TestString", + stringFieldNullable = null + ) + ) + .execute() + + val result = allTypesSchema.getPrimitive(id = id).execute() + + val primitive = result.data.primitive ?: error("result.data.primitive is null") + assertThat(primitive.idFieldNullable).isNull() + assertThat(primitive.intFieldNullable).isNull() + assertThat(primitive.floatFieldNullable).isNull() + assertThat(primitive.booleanFieldNullable).isNull() + assertThat(primitive.stringFieldNullable).isNull() + } + + @Test + fun executeWithAllListOfPrimitiveGraphQLTypesInData() = runTest { + // NOTE: `null` list elements (a.k.a. "sparse arrays") are not supported: b/300331607 + val id = randomId() + allTypesSchema + .createPrimitiveList( + AllTypesSchema.PrimitiveListData( + id = id, + idListNullable = + listOf("1c2a5a6df81c4252ac86383bb93d3dfb", "b53f44ae5be94354b58d10db98690954"), + idListOfNullable = + listOf("e87004fcb45d4b838ccb3ffca5c98e8d", "ad08635e7b4945119b6edaa3b390235e"), + intList = listOf(42, 43, 44), + intListNullable = listOf(45, 46), + intListOfNullable = listOf(47, 48), + floatList = listOf(12.3, 45.6, 78.9), + floatListNullable = listOf(98.7, 65.4), + floatListOfNullable = listOf(100.1, 100.2), + booleanList = listOf(true, false, true, false), + booleanListNullable = listOf(false, true, false, true), + booleanListOfNullable = listOf(false, false, true, true), + stringList = listOf("xxx", "yyy", "zzz"), + stringListNullable = listOf("qqq", "rrr"), + stringListOfNullable = listOf("sss", "ttt"), + ) + ) + .execute() + + allTypesSchema.getAllPrimitiveLists.execute() + + val result = allTypesSchema.getPrimitiveList(id = id).execute() + + val primitive = result.data.primitiveList ?: error("result.data.primitiveList is null") + assertThat(primitive.id).isEqualTo(id) + assertThat(primitive.idListNullable) + .containsExactly("1c2a5a6df81c4252ac86383bb93d3dfb", "b53f44ae5be94354b58d10db98690954") + .inOrder() + assertThat(primitive.idListOfNullable) + .containsExactly("e87004fcb45d4b838ccb3ffca5c98e8d", "ad08635e7b4945119b6edaa3b390235e") + .inOrder() + assertThat(primitive.intList).containsExactly(42, 43, 44).inOrder() + assertThat(primitive.intListNullable).containsExactly(45, 46).inOrder() + assertThat(primitive.intListOfNullable).containsExactly(47, 48).inOrder() + assertThat(primitive.floatList).containsExactly(12.3, 45.6, 78.9).inOrder() + assertThat(primitive.floatListNullable).containsExactly(98.7, 65.4).inOrder() + assertThat(primitive.floatListOfNullable).containsExactly(100.1, 100.2).inOrder() + assertThat(primitive.booleanList).containsExactly(true, false, true, false).inOrder() + assertThat(primitive.booleanListNullable).containsExactly(false, true, false, true).inOrder() + assertThat(primitive.booleanListOfNullable).containsExactly(false, false, true, true).inOrder() + assertThat(primitive.stringList).containsExactly("xxx", "yyy", "zzz").inOrder() + assertThat(primitive.stringListNullable).containsExactly("qqq", "rrr").inOrder() + assertThat(primitive.stringListOfNullable).containsExactly("sss", "ttt").inOrder() + } + + @Test + fun executeWithNestedTypesInData() = runTest { + val farmer1Id = randomFarmerId() + val farmer2Id = randomFarmerId() + val farmer3Id = randomFarmerId() + val farmer4Id = randomFarmerId() + val farmId = randomFarmId() + val animal1Id = randomAnimalId() + val animal2Id = randomAnimalId() + val animal3Id = randomAnimalId() + val animal4Id = randomAnimalId() + allTypesSchema.createFarmer(id = farmer1Id, name = "Farmer1Name", parentId = null).execute() + allTypesSchema + .createFarmer(id = farmer2Id, name = "Farmer2Name", parentId = farmer1Id) + .execute() + allTypesSchema + .createFarmer(id = farmer3Id, name = "Farmer3Name", parentId = farmer2Id) + .execute() + allTypesSchema + .createFarmer(id = farmer4Id, name = "Farmer4Name", parentId = farmer3Id) + .execute() + allTypesSchema.createFarm(id = farmId, name = "TestFarm", farmerId = farmer4Id).execute() + allTypesSchema + .createAnimal( + id = animal1Id, + farmId = farmId, + name = "Animal1Name", + species = "Animal1Species", + age = 1 + ) + .execute() + allTypesSchema + .createAnimal( + id = animal2Id, + farmId = farmId, + name = "Animal2Name", + species = "Animal2Species", + age = 2 + ) + .execute() + allTypesSchema + .createAnimal( + id = animal3Id, + farmId = farmId, + name = "Animal3Name", + species = "Animal3Species", + age = 3 + ) + .execute() + allTypesSchema + .createAnimal( + id = animal4Id, + farmId = farmId, + name = "Animal4Name", + species = "Animal4Species", + age = null + ) + .execute() + + val result = allTypesSchema.getFarm(farmId).execute() + + assertWithMessage("result.data.farm").that(result.data.farm).isNotNull() + val farm = result.data.farm!! + assertThat(farm.id).isEqualTo(farmId) + assertThat(farm.name).isEqualTo("TestFarm") + assertWithMessage("farm.farmer") + .that(farm.farmer) + .isEqualTo( + AllTypesSchema.GetFarmQuery.Farmer( + id = farmer4Id, + name = "Farmer4Name", + parent = + AllTypesSchema.GetFarmQuery.Parent( + id = farmer3Id, + name = "Farmer3Name", + parentId = farmer2Id, + ) + ) + ) + assertWithMessage("farm.animals") + .that(farm.animals) + .containsExactly( + AllTypesSchema.GetFarmQuery.Animal( + id = animal1Id, + name = "Animal1Name", + species = "Animal1Species", + age = 1 + ), + AllTypesSchema.GetFarmQuery.Animal( + id = animal2Id, + name = "Animal2Name", + species = "Animal2Species", + age = 2 + ), + AllTypesSchema.GetFarmQuery.Animal( + id = animal3Id, + name = "Animal3Name", + species = "Animal3Species", + age = 3 + ), + AllTypesSchema.GetFarmQuery.Animal( + id = animal4Id, + name = "Animal4Name", + species = "Animal4Species", + age = null + ), + ) + } + + @Test + fun executeWithNestedNullTypesInData() = runTest { + val farmerId = randomFarmerId() + val farmId = randomFarmId() + allTypesSchema.createFarmer(id = farmerId, name = "FarmerName", parentId = null).execute() + allTypesSchema.createFarm(id = farmId, name = "TestFarm", farmerId = farmerId).execute() + + val result = allTypesSchema.getFarm(farmId).execute() + + assertWithMessage("result.data.farm").that(result.data.farm).isNotNull() + result.data.farm!!.apply { + assertThat(id).isEqualTo(farmId) + assertThat(name).isEqualTo("TestFarm") + assertWithMessage("farm.farmer.parent").that(farmer.parent).isNull() + } + } + + @Test + fun executeShouldThrowIfDataConnectInstanceIsClosed() = runTest { + personSchema.dataConnect.close() + + val result = personSchema.getPerson(id = "foo").runCatching { execute() } + + assertWithMessage("result=${result.getOrNull()}").that(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun executeShouldSupportMassiveConcurrency() = + runTest(timeout = 60.seconds) { + val latch = SuspendingCountDownLatch(25_000) + val query = personSchema.getPerson(id = "foo") + + val deferreds = + List(latch.count) { + // Use `Dispatchers.Default` as the dispatcher for the launched coroutines so that there + // will be at least 2 threads used to run the coroutines (as documented by + // `Dispatchers.Default`), introducing a guaranteed minimum level of parallelism, ensuring + // that this test is indeed testing "massive concurrency". + backgroundScope.async(Dispatchers.Default) { + latch.countDown().await() + query.execute() + } + } + + val results = deferreds.map { it.await() } + results.forEachIndexed { index, result -> + assertWithMessage("results[$index]").that(result.data.person).isNull() + } + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt new file mode 100644 index 00000000000..a83d7b87b89 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt @@ -0,0 +1,601 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + +package com.google.firebase.dataconnect + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.firebase.dataconnect.core.QuerySubscriptionInternal +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonQuery +import com.google.firebase.dataconnect.testutil.schemas.randomPersonId +import com.google.firebase.dataconnect.testutil.skipItemsWhere +import com.google.firebase.dataconnect.testutil.withDataDeserializer +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +import org.junit.Test + +class QuerySubscriptionIntegrationTest : DataConnectIntegrationTestBase() { + + private val schema by lazy { PersonSchema(dataConnectFactory) } + + @Test + fun lastResult_should_be_null_on_new_instance() { + val querySubscription = + schema.getPerson(id = "42").subscribe() + as QuerySubscriptionInternal + assertThat(querySubscription.lastResult).isNull() + } + + @Test + fun lastResult_should_be_equal_to_the_last_collected_result() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name1").execute() + val querySubscription = + schema.getPerson(id = personId).subscribe() + as QuerySubscriptionInternal + + querySubscription.flow.test { + val result1A = awaitItem() + assertWithMessage("result1A.name") + .that(result1A.result.getOrThrow().data.person?.name) + .isEqualTo("Name1") + assertWithMessage("lastResult1").that(querySubscription.lastResult).isEqualTo(result1A) + } + + schema.updatePerson(id = personId, name = "Name2", age = 2).execute() + + querySubscription.flow.test { + val result1B = awaitItem() + assertWithMessage("result1B").that(result1B).isEqualTo(querySubscription.lastResult) + val result2 = awaitItem() + assertWithMessage("result2.name") + .that(result2.result.getOrThrow().data.person?.name) + .isEqualTo("Name2") + assertWithMessage("lastResult2").that(querySubscription.lastResult).isEqualTo(result2) + } + } + + @Test + fun reload_should_notify_collecting_flows() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name1").execute() + val queryRef = schema.getPerson(id = personId) + val querySubscription = schema.getPerson(id = personId).subscribe() + + querySubscription.flow.test { + assertWithMessage("result1") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("Name1") + + schema.updatePerson(id = personId, name = "Name2").execute() + queryRef.execute() + + assertWithMessage("result2") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("Name2") + } + } + + @Test + fun flow_collect_should_get_immediately_invoked_with_last_result() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "TestName").execute() + val querySubscription = schema.getPerson(id = personId).subscribe() + + val result1 = querySubscription.flow.first() + assertWithMessage("result1") + .that(result1.result.getOrThrow().data.person?.name) + .isEqualTo("TestName") + + val result2 = querySubscription.flow.first() + assertWithMessage("result2") + .that(result2.result.getOrThrow().data.person?.name) + .isEqualTo("TestName") + } + + @Test + fun flow_collect_should_get_immediately_invoked_with_last_result_from_other_subscribers() = + runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "TestName").execute() + val querySubscription1 = schema.getPerson(id = personId).subscribe() + val querySubscription2 = schema.getPerson(id = personId).subscribe() + + // Start collecting on `querySubscription1` and wait for it to get its first event. + val subscription1ResultReceived = MutableStateFlow(false) + backgroundScope.launch { + querySubscription1.flow.onEach { subscription1ResultReceived.value = true }.collect() + } + subscription1ResultReceived.filter { it }.first() + + // With `querySubscription1` still alive, start collecting on `querySubscription2`. Expect it + // to initially get the cached result from `querySubscription1`, followed by an updated + // result. + schema.updatePerson(id = personId, name = "NewTestName").execute() + querySubscription2.flow.test { + assertWithMessage("result1") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("TestName") + assertWithMessage("result1") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewTestName") + } + } + + @Test + fun slow_flows_do_not_block_fast_flows() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name0").execute() + val queryRef = schema.getPerson(id = personId) + val querySubscription = queryRef.subscribe() + + turbineScope { + val fastFlow = querySubscription.flow.testIn(backgroundScope) + assertWithMessage("fastFlow") + .that(fastFlow.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("Name0") + + val slowFlowStarted = MutableStateFlow(false) + val slowFlowEnabled = MutableStateFlow(false) + val slowFlow = + querySubscription.flow + .onEach { + slowFlowStarted.value = true + slowFlowEnabled.awaitTrue() + } + .testIn(backgroundScope) + slowFlowStarted.awaitTrue() + + repeat(3) { + schema.updatePerson(id = personId, name = "NewName$it").execute() + queryRef.execute() + } + + fastFlow.run { + skipItemsWhere { it.result.getOrThrow().data.person?.name == "Name0" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName0") + } + skipItemsWhere { it.result.getOrThrow().data.person?.name == "NewName0" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName1") + } + skipItemsWhere { it.result.getOrThrow().data.person?.name == "NewName1" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName2") + } + } + + slowFlowEnabled.value = true + slowFlow.run { + assertWithMessage("slowFlow") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("Name0") + skipItemsWhere { it.result.getOrThrow().data.person?.name == "Name0" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName0") + } + skipItemsWhere { it.result.getOrThrow().data.person?.name == "NewName0" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName1") + } + skipItemsWhere { it.result.getOrThrow().data.person?.name == "NewName1" } + .let { + assertWithMessage("fastFlow") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("NewName2") + } + } + } + } + + @Test + fun reload_delivers_result_to_all_registered_flows_on_all_QuerySubscriptions() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "OriginalName").execute() + val querySubscription1 = schema.getPerson(id = personId).subscribe() + val querySubscription2 = schema.getPerson(id = personId).subscribe() + + turbineScope { + val flow1a = + querySubscription1.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + val flow1b = + querySubscription1.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + val flow2 = + querySubscription2.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + + schema.updatePerson(id = personId, name = "NewName").execute() + schema.getPerson(id = personId).execute() + + assertWithMessage("flow1a") + .that(flow1a.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + assertWithMessage("flow1b") + .that(flow1b.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + assertWithMessage("flow2") + .that(flow2.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + } + } + + @Test + fun queryref_execute_delivers_result_to_QuerySubscriptions() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "OriginalName").execute() + val querySubscription1 = schema.getPerson(id = personId).subscribe() + val querySubscription2 = schema.getPerson(id = personId).subscribe() + + turbineScope { + val flow1a = + querySubscription1.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + val flow1b = + querySubscription1.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + val flow2 = + querySubscription2.flow.filterNotPersonName("OriginalName").testIn(backgroundScope) + + schema.updatePerson(id = personId, name = "NewName").execute() + schema.getPerson(id = personId).execute() + + assertWithMessage("flow1a") + .that(flow1a.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + assertWithMessage("flow1b") + .that(flow1b.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + assertWithMessage("flow2") + .that(flow2.awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + } + } + + @Test + fun reload_concurrent_invocations_get_conflated() = + runTest(timeout = 60.seconds) { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "OriginalName").execute() + val query = schema.getPerson(id = personId) + val querySubscription = query.subscribe() + + querySubscription.flow.test { + assertThat(awaitItem().result.getOrThrow().data.person?.name).isEqualTo("OriginalName") + schema.updatePerson(id = personId, name = "NewName").execute() + + buildList { + repeat(10_000) { + // Run on Dispatchers.Default to ensure some level of concurrency. + add(backgroundScope.async(Dispatchers.Default) { query.execute() }) + } + } + .forEach { it.await() } + + // Flow on Dispatchers.Default so that the timeout actually works, since the default + // dispatcher is the _test_ dispatcher, which skips delays/timeouts. + val results = + asChannel() + .receiveAsFlow() + .timeout(1.seconds) + .flowOn(Dispatchers.Default) + .catch { if (it !is TimeoutCancellationException) throw it } + .toList() + assertWithMessage("results.size").that(results.size).isGreaterThan(0) + assertWithMessage("results.size").that(results.size).isLessThan(2000) + results.forEachIndexed { i, result -> + assertWithMessage("results[$i]") + .that(result.result.getOrThrow().data.person?.name) + .isEqualTo("NewName") + } + } + } + + @Test + fun update_changes_variables_and_triggers_reload() = runTest { + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + schema.createPerson(id = person1Id, name = "Name1").execute() + schema.createPerson(id = person2Id, name = "Name2").execute() + schema.createPerson(id = person3Id, name = "Name3").execute() + val query = schema.getPerson(id = person1Id) + val querySubscription = + query.subscribe() as QuerySubscriptionInternal + + querySubscription.flow.test { + Pair(assertWithMessage("result1"), awaitItem()).let { (assert, result) -> + assert.that(result.result.getOrThrow().ref).isSameInstanceAs(query) + assert.that(result.result.getOrThrow().data.person?.name).isEqualTo("Name1") + } + querySubscription.update(GetPersonQuery.Variables(person2Id)) + Pair(assertWithMessage("result2"), awaitItem()).let { (assert, result) -> + assert + .that(result.result.getOrThrow().ref.variables) + .isEqualTo(GetPersonQuery.Variables(person2Id)) + assert.that(result.result.getOrThrow().data.person?.name).isEqualTo("Name2") + } + querySubscription.update(GetPersonQuery.Variables(person3Id)) + Pair(assertWithMessage("result3"), awaitItem()).let { (assert, result) -> + assert + .that(result.result.getOrThrow().ref.variables) + .isEqualTo(GetPersonQuery.Variables(person3Id)) + assert.that(result.result.getOrThrow().data.person?.name).isEqualTo("Name3") + } + } + } + + @Test + fun reload_updates_last_result_even_if_no_active_collectors() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name1").execute() + val query = schema.getPerson(id = personId) + val querySubscription = + query.subscribe() as QuerySubscriptionInternal + + querySubscription.reload() + + Pair(assertWithMessage("lastResult"), querySubscription.lastResult).let { (assert, lastResult) + -> + assert.that(lastResult!!.result.getOrThrow().data.person?.name).isEqualTo("Name1") + } + + schema.updatePerson(id = personId, name = "Name2").execute() + querySubscription.flow.test { + // Ensure that the first result comes from cache, followed by the updated result received from + // the server when a reload was triggered by the flow's collection. + assertThat(awaitItem().result.getOrThrow().data.person?.name).isEqualTo("Name1") + assertThat(awaitItem().result.getOrThrow().data.person?.name).isEqualTo("Name2") + } + } + + @Test + fun update_updates_last_result_even_if_no_active_collectors() = runTest { + val person1Id = randomPersonId() + val person2Id = randomPersonId() + schema.createPerson(id = person1Id, name = "Name1").execute() + schema.createPerson(id = person2Id, name = "Name2").execute() + val querySubscription = + schema.getPerson(id = person1Id).subscribe() + as QuerySubscriptionInternal + + querySubscription.update(GetPersonQuery.Variables(person2Id)) + + Pair(assertWithMessage("lastResult"), querySubscription.lastResult).let { (assert, lastResult) + -> + assert.that(lastResult!!.result.getOrThrow().data.person?.name).isEqualTo("Name2") + } + + schema.updatePerson(id = person2Id, name = "NewName2").execute() + querySubscription.flow.test { + // Ensure that the first result comes from cache, followed by the updated result received from + // the server when a reload was triggered by the flow's collection. + assertThat(awaitItem().result.getOrThrow().data.person?.name).isEqualTo("Name2") + assertThat(awaitItem().result.getOrThrow().data.person?.name).isEqualTo("NewName2") + } + } + + @Test + fun collect_gets_an_update_on_error() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name1").execute() + val query = schema.getPerson(personId) + val noName2Query = query.withDataDeserializer(serializer()) + + turbineScope { + val querySubscription = noName2Query.subscribe() + val flow = querySubscription.flow.testIn(backgroundScope) + assertThat(flow.awaitItem().result.getOrThrow().data.person?.name).isEqualTo("Name1") + + schema.updatePerson(id = personId, name = "Name2").execute() + val result2 = runCatching { noName2Query.execute() } + assertWithMessage("result2.isSuccess").that(result2.isSuccess).isFalse() + assertThat(flow.awaitItem().result.exceptionOrNull()).isNotNull() + + schema.updatePerson(id = personId, name = "Name3").execute() + noName2Query.execute() + assertThat(flow.awaitItem().result.getOrThrow().data.person?.name).isEqualTo("Name3") + } + } + + @Test + fun collect_gets_notified_of_per_data_deserializer_successes() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "Name0").execute() + + val noName1Query = + schema.getPerson(personId).withDataDeserializer(serializer()) + val noName2Query = + schema.getPerson(personId).withDataDeserializer(serializer()) + + turbineScope { + val noName1Flow = noName1Query.subscribe().flow.testIn(backgroundScope) + val noName2Flow = noName2Query.subscribe().flow.testIn(backgroundScope) + + schema.updatePerson(id = personId, name = "Name1").execute() + schema.getPerson(personId).execute() + noName1Flow + .skipItemsWhere { it.result.getOrNull()?.data?.person?.name == "Name0" } + .let { assertThat(it.result.exceptionOrNull()).isNotNull() } + noName2Flow + .skipItemsWhere { it.result.getOrThrow().data.person?.name == "Name0" } + .let { assertThat(it.result.getOrThrow().data.person?.name).isEqualTo("Name1") } + + schema.updatePerson(id = personId, name = "Name2").execute() + schema.getPerson(personId).execute() + noName1Flow + .skipItemsWhere { it.result.isFailure } + .let { assertThat(it.result.getOrThrow().data.person?.name).isEqualTo("Name2") } + noName2Flow + .skipItemsWhere { it.result.getOrNull()?.data?.person?.name == "Name1" } + .let { assertThat(it.result.exceptionOrNull()).isNotNull() } + + schema.updatePerson(id = personId, name = "Name3").execute() + schema.getPerson(personId).execute() + noName1Flow + .skipItemsWhere { it.result.getOrThrow().data.person?.name == "Name2" } + .let { assertThat(it.result.getOrThrow().data.person?.name).isEqualTo("Name3") } + noName2Flow + .skipItemsWhere { it.result.isFailure } + .let { assertThat(it.result.getOrThrow().data.person?.name).isEqualTo("Name3") } + } + } + + @Test + fun collect_gets_notified_of_previous_cached_success_even_if_most_recent_fails() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "OriginalName").execute() + + val noName1Query = + schema.getPerson(personId).withDataDeserializer(serializer()) + + backgroundScope.launch { noName1Query.subscribe().flow.collect() } + + noName1Query.execute() + + schema.updatePerson(id = personId, name = "Name1").execute() + + noName1Query.subscribe().flow.test { + assertWithMessage("cached result") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("OriginalName") + + skipItemsWhere { it.result.getOrNull()?.data?.person?.name == "OriginalName" } + .let { assertWithMessage("error result").that(it.result.exceptionOrNull()).isNotNull() } + + schema.updatePerson(id = personId, name = "UltimateName").execute() + schema.getPerson(personId).execute() + + skipItemsWhere { it.result.isFailure } + .let { + assertWithMessage("ultimate result") + .that(it.result.getOrThrow().data.person?.name) + .isEqualTo("UltimateName") + } + } + } + + @Test + fun collect_gets_cached_result_even_if_new_data_deserializer() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "OriginalName").execute() + keepCacheAlive(schema.getPerson(personId).withDataDeserializer(DataConnectUntypedData)) + + schema.updatePerson(id = personId, name = "UltimateName").execute() + + schema.getPerson(personId).subscribe().flow.test { + assertWithMessage("result1") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("OriginalName") + assertWithMessage("result2") + .that(awaitItem().result.getOrThrow().data.person?.name) + .isEqualTo("UltimateName") + } + } + + private sealed class RejectSpecificNameKSerializer(val nameToReject: String) : + KSerializer { + override val descriptor = PrimitiveSerialDescriptor("name", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder) = + decoder.decodeString().also { + if (it == nameToReject) { + throw RejectedName("name rejected: $it") + } + } + + override fun serialize(encoder: Encoder, value: String) { + throw UnsupportedOperationException("") + } + + class RejectedName(message: String) : Exception(message) + } + + /** + * A "data" type suitable for the [GetPersonQuery] whose deserialization fails if the name happens + * to be "Name1". This behavior is useful when testing the caching behavior when one deserializer + * successfully decodes the data but another one does not. See [GetPersonDataNoName2]. + */ + @Serializable + private data class GetPersonDataNoName1(val person: Person?) { + @Serializable + data class Person( + @Serializable(with = NameKSerializer::class) val name: String, + val age: Int? + ) { + private object NameKSerializer : RejectSpecificNameKSerializer("Name1") + } + } + + /** + * A "data" type suitable for the [GetPersonQuery] whose deserialization fails if the name happens + * to be "Name2". This behavior is useful when testing the caching behavior when one deserializer + * successfully decodes the data but another one does not. See [GetPersonDataNoName1]. + */ + @Serializable + private data class GetPersonDataNoName2(val person: Person?) { + @Serializable + data class Person( + @Serializable(with = NameKSerializer::class) val name: String, + val age: Int? + ) { + private object NameKSerializer : RejectSpecificNameKSerializer("Name2") + } + } + + /** + * Starts a background coroutine that subscribes to and collects the given query with the given + * variables. Suspends until the first result has been collected. This effectively ensures that + * the cache for the query with the given variables never gets garbage collected. + */ + private suspend fun TestScope.keepCacheAlive(query: QueryRef<*, *>) { + val cachePrimed = MutableStateFlow(false) + backgroundScope.launch { query.subscribe().flow.onEach { cachePrimed.value = true }.collect() } + cachePrimed.awaitTrue() + } + + private companion object { + fun Flow>.filterNotPersonName( + nameToFilterOut: String + ) = filter { it.result.map { it.data.person?.name != nameToFilterOut }.getOrDefault(true) } + + suspend fun MutableStateFlow.awaitTrue() { + filter { it }.first() + } + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppIdTestUtil.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppIdTestUtil.kt new file mode 100644 index 00000000000..df5ab05f106 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppIdTestUtil.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.firebase.dataconnect.R + +fun getFirebaseAppIdFromStrings(): String = + InstrumentationRegistry.getInstrumentation().context.getString(R.string.google_app_id) diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/InProcessDataConnectGrpcServer.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/InProcessDataConnectGrpcServer.kt new file mode 100644 index 00000000000..9598c09fd6d --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/InProcessDataConnectGrpcServer.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import google.firebase.dataconnect.proto.ConnectorServiceGrpc +import google.firebase.dataconnect.proto.ExecuteMutationRequest +import google.firebase.dataconnect.proto.ExecuteMutationResponse +import google.firebase.dataconnect.proto.ExecuteQueryRequest +import google.firebase.dataconnect.proto.ExecuteQueryResponse +import google.firebase.dataconnect.proto.executeMutationResponse +import google.firebase.dataconnect.proto.executeQueryResponse +import io.grpc.InsecureServerCredentials +import io.grpc.Metadata +import io.grpc.Server +import io.grpc.ServerCall +import io.grpc.ServerCallHandler +import io.grpc.ServerInterceptor +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.okhttp.OkHttpServerBuilder +import io.grpc.stub.StreamObserver +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * A JUnit test rule that creates a GRPC server that listens on a local port and can be used in lieu + * of a real GRPC server. + */ +class InProcessDataConnectGrpcServer : + FactoryTestRule< + InProcessDataConnectGrpcServer.ServerInfo, InProcessDataConnectGrpcServer.Params + >() { + + fun newInstance( + errors: List? = null, + executeQueryResponse: ExecuteQueryResponse? = null, + executeMutationResponse: ExecuteMutationResponse? = null + ): ServerInfo = + createInstance( + errors = errors, + executeQueryResponse = executeQueryResponse, + executeMutationResponse = executeMutationResponse + ) + + override fun createInstance(params: Params?): ServerInfo { + return createInstance( + params?.errors, + params?.executeQueryResponse, + params?.executeMutationResponse + ) + } + + private fun createInstance( + errors: List? = null, + executeQueryResponse: ExecuteQueryResponse? = null, + executeMutationResponse: ExecuteMutationResponse? = null + ): ServerInfo { + val serverInterceptor = ServerInterceptorImpl(errors ?: Params.defaults.errors) + val connectorService = + ConnectorServiceImpl( + executeQueryResponse ?: Params.defaults.executeQueryResponse, + executeMutationResponse ?: Params.defaults.executeMutationResponse + ) + val grpcServer = + OkHttpServerBuilder.forPort(0, InsecureServerCredentials.create()) + .addService(connectorService) + .intercept(serverInterceptor) + .build() + grpcServer.start() + return ServerInfo(grpcServer, serverInterceptor.metadatas) + } + + data class Params( + val errors: List = emptyList(), + val executeQueryResponse: ExecuteQueryResponse? = null, + val executeMutationResponse: ExecuteMutationResponse? = null + ) { + companion object { + val defaults = Params() + } + } + + override fun destroyInstance(instance: ServerInfo) { + instance.server.shutdownNow() + } + + data class ServerInfo(val server: Server, val metadatas: Flow) + + private class ServerInterceptorImpl(errors: List = emptyList()) : ServerInterceptor { + + private val errors = errors.toList().iterator() + + private val _metadatas = + MutableSharedFlow(replay = Int.MAX_VALUE, onBufferOverflow = DROP_OLDEST) + + val metadatas = _metadatas.asSharedFlow() + + override fun interceptCall( + call: ServerCall, + headers: Metadata, + next: ServerCallHandler + ): ServerCall.Listener { + check(_metadatas.tryEmit(headers)) { "_metadatas.tryEmit(headers) failed" } + + synchronized(errors) { + if (errors.hasNext()) { + throw StatusException(errors.next()) + } + } + + return next.startCall(call, headers) + } + } + + private class ConnectorServiceImpl( + val executeQueryResponse: ExecuteQueryResponse? = null, + val executeMutationResponse: ExecuteMutationResponse? = null + ) : ConnectorServiceGrpc.ConnectorServiceImplBase() { + override fun executeQuery( + request: ExecuteQueryRequest, + responseObserver: StreamObserver + ) { + val responseData = buildStructProto { put("foo", "prj5hbhqcw") } + val response = + executeQueryResponse ?: ExecuteQueryResponse.newBuilder().setData(responseData).build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + + override fun executeMutation( + request: ExecuteMutationRequest, + responseObserver: StreamObserver + ) { + val responseData = buildStructProto { put("foo", "weevgvyecf") } + val response = + executeMutationResponse + ?: ExecuteMutationResponse.newBuilder().setData(responseData).build() + responseObserver.onNext(response) + responseObserver.onCompleted() + } + } +} + +fun TestDataConnectFactory.Params.copy( + serverInfo: InProcessDataConnectGrpcServer.ServerInfo +): TestDataConnectFactory.Params = + copy( + backend = + DataConnectBackend.Custom(host = "127.0.0.1:${serverInfo.server.port}", sslEnabled = false) + ) + +fun TestDataConnectFactory.newInstance( + serverInfo: InProcessDataConnectGrpcServer.ServerInfo +): FirebaseDataConnect = newInstance(TestDataConnectFactory.Params().copy(serverInfo)) + +fun TestDataConnectFactory.newInstance( + firebaseApp: FirebaseApp, + serverInfo: InProcessDataConnectGrpcServer.ServerInfo +): FirebaseDataConnect = + newInstance(TestDataConnectFactory.Params(firebaseApp = firebaseApp).copy(serverInfo)) diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/MutationRefImplTestExtensions.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/MutationRefImplTestExtensions.kt new file mode 100644 index 00000000000..7adba0e8ee5 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/MutationRefImplTestExtensions.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.DataConnectUntypedVariables + +internal fun MutationRef.withVariables( + variables: DataConnectUntypedVariables +): MutationRef = + dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = DataConnectUntypedVariables + ) diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/QueryRefImplTestExtensions.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/QueryRefImplTestExtensions.kt new file mode 100644 index 00000000000..4981b7d4308 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/QueryRefImplTestExtensions.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.DataConnectUntypedVariables + +internal fun QueryRef.withVariables( + variables: DataConnectUntypedVariables +): QueryRef = + dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = DataConnectUntypedVariables + ) diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/AllTypesSchema.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/AllTypesSchema.kt new file mode 100644 index 00000000000..21d14331320 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/AllTypesSchema.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil.schemas + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.copy +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase.Companion.testConnectorConfig +import com.google.firebase.dataconnect.testutil.TestDataConnectFactory +import com.google.firebase.dataconnect.testutil.randomAlphanumericString +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer + +class AllTypesSchema(val dataConnect: FirebaseDataConnect) { + + constructor( + dataConnectFactory: TestDataConnectFactory + ) : this(dataConnectFactory.newInstance(testConnectorConfig.copy(connector = CONNECTOR))) + + init { + dataConnect.config.connector.let { + require(it == CONNECTOR) { + "The given FirebaseDataConnect has connector=$it, but expected $CONNECTOR" + } + } + } + + @Serializable + data class PrimitiveData( + val id: String, + val idFieldNullable: String?, + val intField: Int, + val intFieldNullable: Int?, + // NOTE: GraphQL "Float" type is a "signed double-precision floating-point value", which is + // equivalent to Java and Kotlin's `Double` type. + val floatField: Double, + val floatFieldNullable: Double?, + val booleanField: Boolean, + val booleanFieldNullable: Boolean?, + val stringField: String, + val stringFieldNullable: String?, + ) + + fun createPrimitive(variables: PrimitiveData) = + dataConnect.mutation( + operationName = "createPrimitive", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + object GetPrimitiveQuery { + @Serializable data class Data(val primitive: PrimitiveData?) + @Serializable data class Variables(val id: String) + } + + fun getPrimitive(variables: GetPrimitiveQuery.Variables) = + dataConnect.query( + operationName = "getPrimitive", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getPrimitive(id: String) = getPrimitive(GetPrimitiveQuery.Variables(id = id)) + + @Serializable + data class PrimitiveListData( + val id: String, + val idListNullable: List?, + val idListOfNullable: List, + val intList: List, + val intListNullable: List?, + val intListOfNullable: List, + // NOTE: GraphQL "Float" type is a "signed double-precision floating-point value", which is + // equivalent to Java and Kotlin's `Double` type. + val floatList: List, + val floatListNullable: List?, + val floatListOfNullable: List, + val booleanList: List, + val booleanListNullable: List?, + val booleanListOfNullable: List, + val stringList: List, + val stringListNullable: List?, + val stringListOfNullable: List, + ) + + fun createPrimitiveList(variables: PrimitiveListData) = + dataConnect.mutation( + operationName = "createPrimitiveList", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + object GetPrimitiveListQuery { + @Serializable data class Data(val primitiveList: PrimitiveListData?) + @Serializable data class Variables(val id: String) + } + + fun getPrimitiveList(variables: GetPrimitiveListQuery.Variables) = + dataConnect.query( + operationName = "getPrimitiveList", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getPrimitiveList(id: String) = getPrimitiveList(GetPrimitiveListQuery.Variables(id = id)) + + object GetAllPrimitiveListsQuery { + @Serializable data class Data(val primitiveLists: List) + } + + val getAllPrimitiveLists + get() = + dataConnect.query( + operationName = "getAllPrimitiveLists", + variables = Unit, + dataDeserializer = serializer(), + variablesSerializer = serializer() + ) + + object CreateFarmerMutation { + @Serializable data class Variables(val id: String, val name: String, val parentId: String?) + } + + fun createFarmer(variables: CreateFarmerMutation.Variables) = + dataConnect.mutation( + operationName = "createFarmer", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createFarmer(id: String, name: String, parentId: String?) = + createFarmer(CreateFarmerMutation.Variables(id = id, name = name, parentId = parentId)) + + object CreateFarmMutation { + @Serializable data class Variables(val id: String, val name: String, val farmerId: String?) + } + + fun createFarm(variables: CreateFarmMutation.Variables) = + dataConnect.mutation( + operationName = "createFarm", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createFarm(id: String, name: String, farmerId: String?) = + createFarm(CreateFarmMutation.Variables(id = id, name = name, farmerId = farmerId)) + + object CreateAnimalMutation { + @Serializable + data class Variables( + val id: String, + val farmId: String, + val name: String, + val species: String, + val age: Int? + ) + } + + fun createAnimal(variables: CreateAnimalMutation.Variables) = + dataConnect.mutation( + operationName = "createAnimal", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createAnimal(id: String, farmId: String, name: String, species: String, age: Int?) = + createAnimal( + CreateAnimalMutation.Variables( + id = id, + farmId = farmId, + name = name, + species = species, + age = age + ) + ) + + object GetFarmQuery { + @Serializable data class Data(val farm: Farm?) + + @Serializable + data class Farm( + val id: String, + val name: String, + val farmer: Farmer, + val animals: List + ) + + @Serializable data class Farmer(val id: String, val name: String, val parent: Parent?) + + @Serializable data class Parent(val id: String, val name: String, val parentId: String?) + + @Serializable + data class Animal(val id: String, val name: String, val species: String, val age: Int?) + + @Serializable data class Variables(val id: String) + } + + fun getFarm(variables: GetFarmQuery.Variables) = + dataConnect.query( + operationName = "getFarm", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getFarm(id: String) = getFarm(GetFarmQuery.Variables(id = id)) + + companion object { + const val CONNECTOR = "alltypes" + } +} + +fun DataConnectIntegrationTestBase.randomFarmerId() = randomAlphanumericString(prefix = "FarmerId") + +fun DataConnectIntegrationTestBase.randomFarmId() = randomAlphanumericString(prefix = "FarmId") + +fun DataConnectIntegrationTestBase.randomAnimalId() = randomAlphanumericString(prefix = "AnimalId") diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt new file mode 100644 index 00000000000..8da1dcf6d94 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt @@ -0,0 +1,259 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil.schemas + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.copy +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase.Companion.testConnectorConfig +import com.google.firebase.dataconnect.testutil.TestDataConnectFactory +import com.google.firebase.dataconnect.testutil.randomAlphanumericString +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer + +class PersonSchema(val dataConnect: FirebaseDataConnect) { + + constructor( + dataConnectFactory: TestDataConnectFactory + ) : this(dataConnectFactory.newInstance(testConnectorConfig.copy(connector = CONNECTOR))) + + init { + dataConnect.config.connector.let { + require(it == CONNECTOR) { + "The given FirebaseDataConnect has connector=$it, but expected $CONNECTOR" + } + } + } + + object CreateDefaultPersonMutation { + @Serializable + data class Data(val person_insert: PersonKey) { + @Serializable data class PersonKey(val id: String) + } + } + + val createDefaultPerson + get() = + dataConnect.mutation( + operationName = "createDefaultPerson", + variables = Unit, + dataDeserializer = serializer(), + variablesSerializer = serializer() + ) + + object CreatePersonMutation { + @Serializable + data class Data(val person_insert: PersonKey) { + @Serializable data class PersonKey(val id: String) + } + @Serializable data class Variables(val id: String, val name: String, val age: Int? = null) + } + + fun createPerson(variables: CreatePersonMutation.Variables) = + dataConnect.mutation( + operationName = "createPerson", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createPersonAuth(id: String, name: String, age: Int? = null) = + createPersonAuth(CreatePersonAuthMutation.Variables(id = id, name = name, age = age)) + + object CreatePersonAuthMutation { + @Serializable + data class Data(val person_insert: PersonKey) { + @Serializable data class PersonKey(val id: String) + } + @Serializable data class Variables(val id: String, val name: String, val age: Int? = null) + } + + fun createPersonAuth(variables: CreatePersonAuthMutation.Variables) = + dataConnect.mutation( + operationName = "createPersonAuth", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createPerson(id: String, name: String, age: Int? = null) = + createPerson(CreatePersonMutation.Variables(id = id, name = name, age = age)) + + object CreateOrUpdatePersonMutation { + @Serializable + data class Data(val person_upsert: PersonKey) { + @Serializable data class PersonKey(val id: String) + } + @Serializable data class Variables(val id: String, val name: String, val age: Int? = null) + } + + fun createOrUpdatePerson(variables: CreateOrUpdatePersonMutation.Variables) = + dataConnect.mutation( + operationName = "createOrUpdatePerson", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun createOrUpdatePerson(id: String, name: String, age: Int? = null) = + createOrUpdatePerson(CreateOrUpdatePersonMutation.Variables(id = id, name = name, age = age)) + + object UpdatePersonMutation { + @Serializable + data class Variables(val id: String, val name: String? = null, val age: Int? = null) + } + + fun updatePerson(variables: UpdatePersonMutation.Variables) = + dataConnect.mutation( + operationName = "updatePerson", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun updatePerson(id: String, name: String? = null, age: Int? = null) = + updatePerson(UpdatePersonMutation.Variables(id = id, name = name, age = age)) + + object DeletePersonMutation { + @Serializable data class Variables(val id: String) + } + + fun deletePerson(variables: DeletePersonMutation.Variables) = + dataConnect.mutation( + operationName = "deletePerson", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun deletePerson(id: String) = deletePerson(DeletePersonMutation.Variables(id = id)) + + object GetPersonQuery { + @Serializable + data class Data(val person: Person?) { + @Serializable data class Person(val name: String, val age: Int? = null) + } + + @Serializable data class Variables(val id: String) + } + + fun getPerson(variables: GetPersonQuery.Variables) = + dataConnect.query( + operationName = "getPerson", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getPerson(id: String) = getPerson(GetPersonQuery.Variables(id = id)) + + object GetPersonAuthQuery { + @Serializable + data class Data(val person: Person?) { + @Serializable data class Person(val name: String, val age: Int? = null) + } + + @Serializable data class Variables(val id: String) + } + + fun getPersonAuth(variables: GetPersonAuthQuery.Variables) = + dataConnect.query( + operationName = "getPersonAuth", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getPersonAuth(id: String) = getPersonAuth(GetPersonAuthQuery.Variables(id = id)) + + object GetPeopleByNameQuery { + @Serializable + data class Data(val people: List) { + @Serializable data class Person(val id: String, val age: Int? = null) + } + + @Serializable data class Variables(val name: String) + } + + fun getPeopleByName(variables: GetPeopleByNameQuery.Variables) = + dataConnect.query( + operationName = "getPeopleByName", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + + fun getPeopleByName(name: String) = getPeopleByName(GetPeopleByNameQuery.Variables(name = name)) + + object GetNoPeopleQuery { + @Serializable + data class Data(val people: List) { + @Serializable data class Person(val id: String) + } + } + + val getNoPeople + get() = + dataConnect.query( + operationName = "getNoPeople", + variables = Unit, + dataDeserializer = serializer(), + variablesSerializer = serializer() + ) + + object GetPeopleWithHardcodedNameQuery { + // These values *must* match the hardcoded values in the graphql source. + val hardcodedPeople + get() = + listOf( + Data.Person(id = "HardcodedNamePerson1Id_v1", age = null), + Data.Person(id = "HardcodedNamePerson2Id_v1", age = 42) + ) + + @Serializable + data class Data(val people: List) { + @Serializable data class Person(val id: String, val age: Int?) + } + } + + val getPeopleWithHardcodedName + get() = + dataConnect.query( + operationName = "getPeopleWithHardcodedName", + variables = Unit, + dataDeserializer = serializer(), + variablesSerializer = serializer() + ) + + val createPeopleWithHardcodedName + get() = + dataConnect.mutation( + operationName = "createPeopleWithHardcodedName", + variables = Unit, + dataDeserializer = serializer(), + variablesSerializer = serializer() + ) + + companion object { + const val CONNECTOR = "person" + } +} + +fun DataConnectIntegrationTestBase.randomPersonId() = randomAlphanumericString(prefix = "PersonId") + +fun DataConnectIntegrationTestBase.randomPersonName() = + randomAlphanumericString(prefix = "PersonName") diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchemaIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchemaIntegrationTest.kt new file mode 100644 index 00000000000..dd6ab0a255b --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchemaIntegrationTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil.schemas + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPeopleWithHardcodedNameQuery.hardcodedPeople +import kotlinx.coroutines.test.* +import org.junit.Test + +class PersonSchemaIntegrationTest : DataConnectIntegrationTestBase() { + + private val schema by lazy { PersonSchema(dataConnectFactory) } + + @Test + fun createPersonShouldCreateTheSpecifiedPerson() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "TestName", age = 42).execute() + + val result = schema.getPerson(id = personId).execute() + + assertThat(result.data.person).isNotNull() + val person = result.data.person!! + assertThat(person.name).isEqualTo("TestName") + assertThat(person.age).isEqualTo(42) + } + + @Test + fun deletePersonShouldDeleteTheSpecifiedPerson() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "TestName", age = 42).execute() + assertThat(schema.getPerson(id = personId).execute().data.person).isNotNull() + + schema.deletePerson(id = personId).execute() + + assertThat(schema.getPerson(id = personId).execute().data.person).isNull() + } + + @Test + fun updatePersonShouldUpdateTheSpecifiedPerson() = runTest { + val personId = randomPersonId() + schema.createPerson(id = personId, name = "TestName0", age = 42).execute() + + schema.updatePerson(id = personId, name = "TestName99", age = 999).execute() + + val result = schema.getPerson(id = personId).execute() + assertThat(result.data.person?.name).isEqualTo("TestName99") + assertThat(result.data.person?.age).isEqualTo(999) + } + + @Test + fun getPersonShouldReturnThePersonWithTheSpecifiedId() = runTest { + val person1Id = randomPersonId() + val person2Id = randomPersonId() + val person3Id = randomPersonId() + schema.createPerson(id = person1Id, name = "Name111", age = 111).execute() + schema.createPerson(id = person2Id, name = "Name222", age = 222).execute() + schema.createPerson(id = person3Id, name = "Name333", age = null).execute() + + val result1 = schema.getPerson(id = person1Id).execute() + val result2 = schema.getPerson(id = person2Id).execute() + val result3 = schema.getPerson(id = person3Id).execute() + + assertThat(result1.data.person).isNotNull() + val person1 = result1.data.person!! + assertThat(person1.name).isEqualTo("Name111") + assertThat(person1.age).isEqualTo(111) + + assertThat(result2.data.person).isNotNull() + val person2 = result2.data.person!! + assertThat(person2.name).isEqualTo("Name222") + assertThat(person2.age).isEqualTo(222) + + assertThat(result3.data.person).isNotNull() + val person3 = result3.data.person!! + assertThat(person3.name).isEqualTo("Name333") + assertThat(person3.age).isNull() + } + + @Test + fun getPersonShouldReturnNullPersonIfThePersonDoesNotExist() = runTest { + schema.deletePerson(id = "IdOfPersonThatDoesNotExit").execute() + + val result = schema.getPerson(id = "IdOfPersonThatDoesNotExit").execute() + + assertThat(result.data.person).isNull() + } + + @Test + fun getNoPeopleShouldReturnEmptyList() = runTest { + assertThat(schema.getNoPeople.execute().data.people).isEmpty() + } + + @Test + fun getPeopleWithHardcodedNameShouldReturnTwoMatches() = runTest { + schema.createPeopleWithHardcodedName.execute() + + val result = schema.getPeopleWithHardcodedName.execute() + + assertThat(result.data.people).containsExactlyElementsIn(hardcodedPeople) + } + + @Test + fun getPeopleByNameShouldReturnThePeopleWithTheGivenName() = runTest { + val personName = randomPersonName() + val person1Id = randomPersonId() + val person2Id = randomPersonId() + schema.createPerson(id = person1Id, name = personName, age = 1).execute() + schema.createPerson(id = person2Id, name = personName, age = 2).execute() + + val result = schema.getPeopleByName(personName).execute() + + assertThat(result.data.people) + .containsExactly( + PersonSchema.GetPeopleByNameQuery.Data.Person(id = person1Id, age = 1), + PersonSchema.GetPeopleByNameQuery.Data.Person(id = person2Id, age = 2), + ) + } +} diff --git a/firebase-dataconnect/src/main/AndroidManifest.xml b/firebase-dataconnect/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..2264f60e0e5 --- /dev/null +++ b/firebase-dataconnect/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/AnyValue.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/AnyValue.kt new file mode 100644 index 00000000000..ec22fde7ce8 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/AnyValue.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.AnyValue.Companion.serializer +import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromValue +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToValue +import com.google.firebase.dataconnect.util.ProtoUtil.toAny +import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import com.google.protobuf.Struct +import com.google.protobuf.Value +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer + +/** + * Represents a variable or field of the Data Connect custom scalar type `Any`. + * + * ### Valid Values for `AnyValue` + * + * `AnyValue` can encapsulate [String], [Boolean], [Double], a [List] of one of these types, or a + * [Map] whose values are one of these types. The values can be arbitrarily nested (e.g. a list that + * contains a map that contains other maps, and so on. The lists and maps can contain heterogeneous + * values; for example, a single [List] can contain a [String] value, some [Boolean] values, and + * some [List] values. The values of a [List] or a [Map] may be `null`. The only exception is that a + * variable or field declared as `[Any]` in GraphQL may _not_ have `null` values in the top-level + * list; however, nested lists or maps _may_ contain null values. + * + * ### Storing `Int` in an `AnyValue` + * + * To store an [Int] value, simply convert it to a [Double] and store the [Double] value. + * + * ### Storing `Long` in an `AnyValue` + * + * To store a [Long] value, converting it to a [Double] can be lossy if the value is sufficiently + * large (or small) to not be exactly represented by [Double]. The _largest_ [Long] value that can + * be stored in a [Double] with its exact value is `2^53 – 1` (`9007199254740991`). The _smallest_ + * [Long] value that can be stored in a [Double] with its exact value is `-(2^53 – 1)` + * (`-9007199254740991`). This limitation is exactly the same in JavaScript, which does not have a + * native "int" or "long" type, but rather stores all numeric values in a 64-bit floating point + * value. See + * [MAX_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER]) + * and + * [MIN_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER]) + * for more details. + * + * ### Integration with `kotlinx.serialization` + * + * To serialize a value of this type when using Data Connect, use [AnyValueSerializer]. + * + * ### Example + * + * For example, suppose this schema and operation is defined in the GraphQL source: + * + * ``` + * type Foo @table { value: Any } + * mutation FooInsert($value: Any) { + * key: foo_insert(data: { value: $value }) + * } + * ``` + * + * then a serializable "Variables" type could be defined as follows: + * + * ``` + * @Serializable + * data class FooInsertVariables( + * @Serializable(with=AnyValueSerializer::class) val value: AnyValue? + * ) + * ``` + */ +@Serializable(with = AnyValueSerializer::class) +public class AnyValue internal constructor(internal val protoValue: Value) { + + init { + require(protoValue.kindCase != Value.KindCase.NULL_VALUE) { + "NULL_VALUE is not allowed; just use null" + } + } + + internal constructor(struct: Struct) : this(struct.toValueProto()) + + /** + * Creates an instance that encapsulates the given [Map]. + * + * An exception is thrown if any of the values of the map, or its sub-values, are invalid for + * being stored in [AnyValue]; see the [AnyValue] class documentation for a detailed description + * of value values. + * + * This class makes a _copy_ of the given map; therefore, any modifications to the map after this + * object is created will have no effect on this [AnyValue] object. + */ + public constructor(value: Map) : this(value.toValueProto()) + + /** + * Creates an instance that encapsulates the given [List]. + * + * An exception is thrown if any of the values of the list, or its sub-values, are invalid for + * being stored in [AnyValue]; see the [AnyValue] class documentation for a detailed description + * of value values. + * + * This class makes a _copy_ of the given list; therefore, any modifications to the list after + * this object is created will have no effect on this [AnyValue] object. + */ + public constructor(value: List) : this(value.toValueProto()) + + /** Creates an instance that encapsulates the given [String]. */ + public constructor(value: String) : this(value.toValueProto()) + + /** Creates an instance that encapsulates the given [Boolean]. */ + public constructor(value: Boolean) : this(value.toValueProto()) + + /** Creates an instance that encapsulates the given [Double]. */ + public constructor(value: Double) : this(value.toValueProto()) + + /** + * The native Kotlin type of the value encapsulated in this object. + * + * Although this type is `Any` it will be one of `String, `Boolean`, `Double`, `List` or + * `Map`. See the [AnyValue] class documentation for a detailed description of the + * types of values that are supported. + */ + public val value: Any + // NOTE: The not-null assertion operator (!!) below will never throw because the `init` block + // of this class asserts that `protoValue` is not NULL_VALUE. + get() = protoValue.toAny()!! + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of [AnyValue] whose encapsulated + * value compares equal using the `==` operator to the given object. + */ + override fun equals(other: Any?): Boolean = other is AnyValue && other.value == value + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, calculated from the encapsulated value. + */ + override fun hashCode(): Int = value.hashCode() + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object's encapsulated value. + */ + override fun toString(): String = protoValue.toCompactString(keySortSelector = { it }) + + /** + * Provides extension functions that can be used independently of a specified [AnyValue] instance. + */ + public companion object +} + +/** + * Decodes the encapsulated value using the given deserializer. + * + * @param deserializer The deserializer for the decoder to use. + * @param serializersModule a [SerializersModule] to use during deserialization; may be `null` (the + * default) to _not_ use a [SerializersModule] to use during deserialization. + * + * @return the object of type `T` created by decoding the encapsulated value using the given + * deserializer. + */ +public fun AnyValue.decode( + deserializer: DeserializationStrategy, + serializersModule: SerializersModule? = null +): T = decodeFromValue(protoValue, deserializer, serializersModule) + +/** + * Decodes the encapsulated value using the _default_ serializer for the return type, as computed by + * [serializer]. + * + * @return the object of type `T` created by decoding the encapsulated value using the _default_ + * serializer for the return type, as computed by [serializer]. + */ +public inline fun AnyValue.decode(): T = decode(serializer()) + +/** + * Encodes the given value using the given serializer to an [AnyValue] object, and returns it. + * + * @param value the value to serialize. + * @param serializer the serializer for the encoder to use. + * @param serializersModule a [SerializersModule] to use during serialization; may be `null` (the + * default) to _not_ use a [SerializersModule] to use during serialization. + * + * @return a new `AnyValue` object whose encapsulated value is the encoding of the given value when + * decoded with the given serializer. + */ +public fun AnyValue.Companion.encode( + value: T, + serializer: SerializationStrategy, + serializersModule: SerializersModule? = null +): AnyValue = AnyValue(encodeToValue(value, serializer, serializersModule)) + +/** + * Encodes the given value using the given _default_ serializer for the given object, as computed by + * [serializer]. + * + * @param value the value to serialize. + * @return a new `AnyValue` object whose encapsulated value is the encoding of the given value when + * decoded with the _default_ serializer for the given object, as computed by [serializer]. + */ +public inline fun AnyValue.Companion.encode(value: T): AnyValue = + encode(value, serializer()) + +/** + * Creates and returns an `AnyValue` object created using the `AnyValue` constructor that + * corresponds to the runtime type of the given value, or returns `null` if the given value is + * `null`. + * + * @throws IllegalArgumentException if the given value is not supported by `AnyValue`; see the + * `AnyValue` constructor for details. + */ +@JvmName("fromNullableAny") +public fun AnyValue.Companion.fromAny(value: Any?): AnyValue? = + if (value === null) null else fromAny(value) + +/** + * Creates and returns an `AnyValue` object created using the `AnyValue` constructor that + * corresponds to the runtime type of the given value. + * + * @throws IllegalArgumentException if the given value is not supported by `AnyValue`; see the + * `AnyValue` constructor for details. + */ +public fun AnyValue.Companion.fromAny(value: Any): AnyValue { + @Suppress("UNCHECKED_CAST") + return when (value) { + is String -> AnyValue(value) + is Boolean -> AnyValue(value) + is Double -> AnyValue(value) + is List<*> -> AnyValue(value) + is Map<*, *> -> AnyValue(value as Map) + else -> + throw IllegalArgumentException( + "unsupported type: ${value::class.qualifiedName}" + + " (supported types: null, String, Boolean, Double, List, Map)" + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/ConnectorConfig.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/ConnectorConfig.kt new file mode 100644 index 00000000000..d1fa9f999ab --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/ConnectorConfig.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import java.util.Objects + +/** + * Information about a Firebase Data Connect "connector" that is used by [FirebaseDataConnect] to + * connect to the correct Google Cloud resources. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [ConnectorConfig] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * @property connector The ID of the Firebase Data Connect "connector". + * @property location The location where the connector is located (e.g. `"us-central1"`). + * @property serviceId The ID of the Firebase Data Connect service. + */ +public class ConnectorConfig( + public val connector: String, + public val location: String, + public val serviceId: String +) { + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of [ConnectorConfig] whose public + * properties compare equal using the `==` operator to the corresponding properties of this + * object. + */ + override fun equals(other: Any?): Boolean = + (other is ConnectorConfig) && + other.connector == connector && + other.location == location && + other.serviceId == serviceId + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int = + Objects.hash(ConnectorConfig::class, connector, location, serviceId) + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String = + "ConnectorConfig(connector=$connector, location=$location, serviceId=$serviceId)" +} + +/** Creates and returns a new [ConnectorConfig] instance with the given property values. */ +public fun ConnectorConfig.copy( + connector: String = this.connector, + location: String = this.location, + serviceId: String = this.serviceId +): ConnectorConfig = + ConnectorConfig(connector = connector, location = location, serviceId = serviceId) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt new file mode 100644 index 00000000000..07e87c212a8 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import java.util.Objects + +// See https://spec.graphql.org/draft/#sec-Errors +internal class DataConnectError( + val message: String, + val path: List, + val locations: List, +) { + + override fun hashCode(): Int = Objects.hash(message, path, locations) + + override fun equals(other: Any?): Boolean = + (other is DataConnectError) && + other.message == message && + other.path == path && + other.locations == locations + + override fun toString(): String = + StringBuilder() + .also { sb -> + path.forEachIndexed { segmentIndex, segment -> + when (segment) { + is PathSegment.Field -> { + if (segmentIndex != 0) { + sb.append('.') + } + sb.append(segment.field) + } + is PathSegment.ListIndex -> { + sb.append('[') + sb.append(segment.index) + sb.append(']') + } + } + } + + if (locations.isNotEmpty()) { + if (sb.isNotEmpty()) { + sb.append(' ') + } + sb.append("at ") + sb.append(locations.joinToString(", ")) + } + + if (path.isNotEmpty() || locations.isNotEmpty()) { + sb.append(": ") + } + + sb.append(message) + } + .toString() + + sealed interface PathSegment { + @JvmInline + value class Field(val field: String) : PathSegment { + override fun toString(): String = field + } + + @JvmInline + value class ListIndex(val index: Int) : PathSegment { + override fun toString(): String = index.toString() + } + } + + class SourceLocation(val line: Int, val column: Int) { + override fun hashCode(): Int = Objects.hash(line, column) + override fun equals(other: Any?): Boolean = + other is SourceLocation && other.line == line && other.column == column + override fun toString(): String = "$line:$column" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectException.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectException.kt new file mode 100644 index 00000000000..7262837e40d --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectException.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** The exception thrown when an error occurs in Firebase Data Connect. */ +public open class DataConnectException(message: String, cause: Throwable? = null) : + Exception(message, cause) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectSettings.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectSettings.kt new file mode 100644 index 00000000000..2d9f5e1c9eb --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectSettings.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import java.util.Objects + +/** + * Settings that control the behavior of [FirebaseDataConnect] instances. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [DataConnectSettings] are thread-safe and may be safely called + * and/or accessed concurrently from multiple threads and/or coroutines. + * + * @property host The host of the Firebase Data Connect service to which to connect (e.g. + * `"myproxy.foo.com"`, `"myproxy.foo.com:9987"`). + * @property sslEnabled Whether to use SSL for the connection; if `true`, then the connection will + * be encrypted using SSL and, if false, the connection will _not_ be encrypted and all network + * transmission will happen in plaintext. + */ +public class DataConnectSettings( + public val host: String = "firebasedataconnect.googleapis.com", + public val sslEnabled: Boolean = true +) { + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of [DataConnectSettings] whose + * public properties compare equal using the `==` operator to the corresponding properties of this + * object. + */ + override fun equals(other: Any?): Boolean = + (other is DataConnectSettings) && other.host == host && other.sslEnabled == sslEnabled + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int = Objects.hash(DataConnectSettings::class, host, sslEnabled) + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String = "DataConnectSettings(host=$host, sslEnabled=$sslEnabled)" +} + +/** Creates and returns a new [DataConnectSettings] instance with the given property values. */ +public fun DataConnectSettings.copy( + host: String = this.host, + sslEnabled: Boolean = this.sslEnabled +): DataConnectSettings = DataConnectSettings(host = host, sslEnabled = sslEnabled) + +internal fun DataConnectSettings.isDefaultHost() = host == DataConnectSettings().host diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt new file mode 100644 index 00000000000..638fffb913e --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import java.util.Objects +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.encoding.Decoder + +internal class DataConnectUntypedData( + val data: Map?, + val errors: List +) { + + override fun equals(other: Any?) = + (other is DataConnectUntypedData) && other.data == data && other.errors == errors + + override fun hashCode() = Objects.hash(data, errors) + + override fun toString() = "DataConnectUntypedData(data=$data, errors=$errors)" + + companion object Deserializer : DeserializationStrategy { + override val descriptor + get() = unsupported() + + override fun deserialize(decoder: Decoder) = unsupported() + + private fun unsupported(): Nothing = + throw UnsupportedOperationException( + "The ${Deserializer::class.qualifiedName} class cannot actually be used; " + + "it is merely a placeholder" + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariables.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariables.kt new file mode 100644 index 00000000000..467402827c2 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariables.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.encoding.Encoder + +internal class DataConnectUntypedVariables(val variables: Map) { + + constructor(vararg pairs: Pair) : this(mapOf(*pairs)) + + constructor(builderAction: MutableMap.() -> Unit) : this(buildMap(builderAction)) + + override fun equals(other: Any?) = + (other is DataConnectUntypedVariables) && other.variables == variables + + override fun hashCode() = variables.hashCode() + + override fun toString() = variables.toString() + + companion object Serializer : SerializationStrategy { + override val descriptor + get() = unsupported() + + override fun serialize(encoder: Encoder, value: DataConnectUntypedVariables) = unsupported() + + private fun unsupported(): Nothing = + throw UnsupportedOperationException( + "The ${Serializer::class.qualifiedName} class cannot actually be used; " + + "it is merely a placeholder" + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/FirebaseDataConnect.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/FirebaseDataConnect.kt new file mode 100644 index 00000000000..a9de86a6ee9 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/FirebaseDataConnect.kt @@ -0,0 +1,426 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import android.annotation.SuppressLint +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.app +import com.google.firebase.dataconnect.core.FirebaseDataConnectFactory +import com.google.firebase.dataconnect.core.LoggerGlobals +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +/** + * Firebase Data Connect is Firebase's first relational database solution for app developers to + * build mobile and web applications using a fully managed PostgreSQL database powered by Cloud SQL. + * + * See + * [https://firebase.google.com/products/data-connect](https://firebase.google.com/products/data-connect) + * for full details about the Firebase Data Connect product. + * + * ### GraphQL Schema and Operation Definition + * + * The database schema and operations to query and mutate the data are authored in GraphQL and + * uploaded to the server. Then, the queries and mutations can be executed by name, providing + * variables along with the name to control their behavior. For example, a mutation that inserts a + * row into a "people" table could be named "InsertPerson" and require a variable for the person's + * name and a variable for the person's age. Similarly, a query to retrieve a row from the "person" + * table by its ID could be named "GetPersonById" and require a variable for the person's ID. + * + * ### Usage with the Generated SDK + * + * [FirebaseDataConnect] is the entry point to the Firebase Data Connect API; however, it is mostly + * intended to be an implementation detail for the code generated by Firebase's tools, which provide + * a type-safe API for running the queries and mutations. The generated classes and functions are + * colloquially referred to as the "generated SDK" and will encapsulate the API defined in this + * package. Applications are generally recommended to use the "generated SDK" rather than using this + * API directly to enjoy the benefits of a type-safe API. + * + * ### Obtaining Instances + * + * To obtain an instance of [FirebaseDataConnect] call [FirebaseDataConnect.Companion.getInstance]. + * If desired, when done with it, release the resources of the instance by calling + * [FirebaseDataConnect.close]. To connect to the Data Connect Emulator (rather than the production + * Data Connect service) call [FirebaseDataConnect.useEmulator]. To create [QueryRef] and + * [MutationRef] instances for running queries and mutations, call [FirebaseDataConnect.query] and + * [FirebaseDataConnect.mutation], respectively. To enable debug logging, which is especially useful + * when reporting issues to Google, set [FirebaseDataConnect.Companion.logLevel] to [LogLevel.DEBUG] + * . + * + * ### Integration with Kotlin Coroutines and Serialization + * + * The Firebase Data Connect API is designed as a Kotlin-only API, and integrates tightly with + * [Kotlin Coroutines](https://developer.android.com/kotlin/coroutines) and + * [Kotlin Serialization](https://github.com/Kotlin/kotlinx.serialization). Applications should + * ensure that they depend on these two official Kotlin extension libraries and enable the Kotlin + * serialization Gradle plugin. + * + * All blocking operations are exposed as `suspend` functions, which can be safely called from the + * main thread. Any blocking and/or CPU-intensive operations are moved off of the calling thread to + * a background dispatcher. + * + * Data sent to the Data Connect server is serialized and data received from the Data Connect server + * is deserialized using Kotlin's Serialization framework. Applications will typically enable the + * official Kotlin Serialization Gradle plugin to automatically generate the serializers and + * deserializers for classes annotated with `@Serializable`. Of course, applications are free to + * write the serializers by hand as well. + * + * ### Release Notes + * + * Release notes for the Firebase Data Connect Android SDK will be published here until it is merged + * into the `master` branch of https://github.com/firebase/firebase-android-sdk, at which point the + * release notes will become part of the regular Android SDK releases. + * + * #### 16.0.0-alpha05 (June 24, 2024) + * - [#6003](https://github.com/firebase/firebase-android-sdk/pull/6003]) Fixed [close] to + * _actually_ close the underlying grpc network resources. Also, added [suspendingClose] to allow + * callers to wait for the asynchronous closing work to complete, such as in integration tests. + * - [#6005](https://github.com/firebase/firebase-android-sdk/pull/6005) Fixed a StrictMode + * violation upon the first network request being sent. + * - [#6006](https://github.com/firebase/firebase-android-sdk/pull/6006) Improved debug logging of + * GRPC requests and responses. + * - [#6038](https://github.com/firebase/firebase-android-sdk/pull/6038) Fixed a bug with incorrect + * Timestamp serialization due to miscalculation in timezone decoding. + * - [#6052](https://github.com/firebase/firebase-android-sdk/pull/6052) Automatically retry + * operations (queries and mutations) that fail due to an expired authentication token, with a new + * authentication token. + * + * #### 16.0.0-alpha04 (May 29, 2024) + * - [#5976](https://github.com/firebase/firebase-android-sdk/pull/5976) Fixed time zone issues when + * serializing java.util.Date objects + * - [#5996](https://github.com/firebase/firebase-android-sdk/pull/5996) Changed default port of + * useEmulator() to 9399 (was 9510); this goes with a change to the Data Connect Emulator v1.1.19 + * (firebase-tools v13.10.2) that changes the default port to 9399. + * + * #### 16.0.0-alpha03 (May 15, 2024) + * - KDoc comments added. + * - OptionalVariable: fix potential NullPointerException in toString() and hashCode(). + * - TimestampSerializer: add support for time zones specified using +HH:MM or -HH:MM. + * + * #### 16.0.0-alpha02 (May 13, 2024) + * - Internal code cleanup; no externally-visible changes. + * + * #### 16.0.0-alpha01 (May 08, 2024) + * - Initial release. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [FirebaseDataConnect] are thread-safe and may be safely called + * and/or accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [FirebaseDataConnect] interface is _not_ stable for inheritance in third-party libraries, as + * new methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface FirebaseDataConnect : AutoCloseable { + + /** + * The [FirebaseApp] instance with which this object is associated. + * + * The [FirebaseApp] object is used for things such as determining the project ID of the Firebase + * project and the configuration of FirebaseAuth. + * + * @see [FirebaseDataConnect.Companion.getInstance] + */ + public val app: FirebaseApp + + /** + * The configuration of the Data Connect "connector" used to connect to the Data Connect service. + * + * @see [FirebaseDataConnect.Companion.getInstance] + */ + public val config: ConnectorConfig + + /** + * The settings of this [FirebaseDataConnect] object, that affect how it behaves. + * + * @see [FirebaseDataConnect.Companion.getInstance] + */ + public val settings: DataConnectSettings + + /** + * Configure this [FirebaseDataConnect] object to connect to the Data Connect Emulator. + * + * This method is typically called immediately after creation of the [FirebaseDataConnect] object + * and must be called before any queries or mutations are executed. An exception will be thrown if + * called after a query or mutation has been executed. Calling this method causes the values in + * [DataConnectSettings.host] and [DataConnectSettings.sslEnabled] to be ignored. + * + * To start the Data Connect emulator from the command line, first install Firebase Tools as + * documented at https://firebase.google.com/docs/emulator-suite/install_and_configure then run + * `firebase emulators:start --only auth,dataconnect`. Enabling the "auth" emulator is only needed + * if using [com.google.firebase.auth.FirebaseAuth] to authenticate users. You may also need to + * specify `--project ` if the Firebase tools are unable to auto-detect the project ID. + * + * @param host The host name or IP address of the Data Connect emulator to which to connect. The + * default value, 10.0.2.2, is a magic IP address that the Android Emulator aliases to the host + * computer's equivalent of `localhost`. + * @param port The TCP port of the Data Connect emulator to which to connect. The default value is + * the default port used + */ + public fun useEmulator(host: String = "10.0.2.2", port: Int = 9399) + + /** Options that can be specified when creating a [QueryRef] via the [query] method. */ + public interface QueryRefOptionsBuilder { + + /** + * The calling SDK information to apply to all operations executed by the corresponding + * [QueryRef] object. May be `null` (the default) in which case [CallerSdkType.Base] will be + * used. + */ + public var callerSdkType: CallerSdkType? + + /** + * A [SerializersModule] to use when encoding the query's variables. May be `null` (the default) + * to _not_ use a [SerializersModule] when encoding the variables. + */ + public var variablesSerializersModule: SerializersModule? + + /** + * A [SerializersModule] to use when decoding the query's response data. May be `null` (the + * default) to _not_ use a [SerializersModule] when decoding the response data. + */ + public var dataSerializersModule: SerializersModule? + } + + /** + * Creates and returns a [QueryRef] for running the specified query. + * @param operationName The value for [QueryRef.operationName] of the returned object. + * @param variables The value for [QueryRef.variables] of the returned object. + * @param dataDeserializer The value for [QueryRef.dataDeserializer] of the returned object. + * @param variablesSerializer The value for [QueryRef.variablesSerializer] of the returned object. + * @param optionsBuilder A method that will be called to provide optional information when + * creating the [QueryRef]; may be `null` (the default) to not perform any customization. + */ + public fun query( + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + optionsBuilder: (QueryRefOptionsBuilder.() -> Unit)? = null, + ): QueryRef + + /** Options that can be specified when creating a [MutationRef] via the [mutation] method. */ + public interface MutationRefOptionsBuilder { + + /** + * The calling SDK information to apply to all operations executed by the corresponding + * [MutationRef] object. May be `null` (the default) in which case [CallerSdkType.Base] will be + * used. + */ + public var callerSdkType: CallerSdkType? + + /** + * A [SerializersModule] to use when encoding the mutation's variables. May be `null` (the + * default) to use some unspecified [SerializersModule] when encoding the variables. + */ + public var variablesSerializersModule: SerializersModule? + + /** + * A [SerializersModule] to use when decoding the mutation's response data. May be `null` (the + * default) to _not_ use a [SerializersModule] when decoding the response data. + */ + public var dataSerializersModule: SerializersModule? + } + + /** + * Creates and returns a [MutationRef] for running the specified mutation. + * @param operationName The value for [MutationRef.operationName] of the returned object. + * @param variables The value for [MutationRef.variables] of the returned object. + * @param dataDeserializer The value for [MutationRef.dataDeserializer] of the returned object. + * @param variablesSerializer The value for [MutationRef.variablesSerializer] of the returned + * object. + * @param optionsBuilder A method that will be called to provide optional information when + * creating the [QueryRef]; may be `null` (the default) to not perform any customization. + */ + public fun mutation( + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + optionsBuilder: (MutationRefOptionsBuilder.() -> Unit)? = null, + ): MutationRef + + /** + * Releases the resources of this object and removes the instance from the instance cache + * maintained by [FirebaseDataConnect.Companion.getInstance]. + * + * This method returns immediately, possibly before in-flight queries and mutations are completed. + * Any future attempts to execute queries or mutations returned from [query] or [mutation] will + * immediately fail. To wait for the in-flight queries and mutations to complete, call + * [suspendingClose] instead. + * + * It is safe to call this method multiple times. On subsequent invocations, if the previous + * closing attempt failed then it will be re-tried. + * + * After this method returns, calling [FirebaseDataConnect.Companion.getInstance] with the same + * [app] and [config] will return a new instance, rather than returning this instance. + * + * @see suspendingClose + */ + override fun close() + + /** + * A version of [close] that has the same semantics, but suspends until the asynchronous work is + * complete. + * + * If the asynchronous work fails, then the exception from the asynchronous work is rethrown by + * this method. + * + * Using this method in tests may be useful to ensure that this object is fully shut down after + * each test case. This is especially true if tests create [FirebaseDataConnect] in rapid + * succession which could starve resources if they are all active simultaneously. In those cases, + * it may be a good idea to call [suspendingClose] instead of [close] to ensure that each instance + * is fully shut down before a new one is created. In normal production applications, where + * instances of [FirebaseDataConnect] are created infrequently, calling [close] should be + * sufficient, and avoids having to create a [CoroutineScope] just to close the object. + * + * @see close + */ + public suspend fun suspendingClose() + + /** + * Compares this object with another object for equality, using the `===` operator. + * + * The implementation of this method simply uses referential equality. That is, two instances of + * [FirebaseDataConnect] compare equal using this method if, and only if, they refer to the same + * object, as determined by the `===` operator. Notably, this makes it suitable for instances of + * [FirebaseDataConnect] to be used as keys in a [java.util.WeakHashMap] in order to store + * supplementary information about the [FirebaseDataConnect] instance. + * + * @param other The object to compare to this for equality. + * @return `other === this` + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * See [equals] for the special guarantees of referential equality that make instances of this + * class suitable for usage as keys in a hash map. + * + * @return the hash code for this object. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String + + /** + * Indicates where the usages of this object are coming from. + * + * This information is merely used for analytics and has no effects on the product's + * functionality. + */ + public enum class CallerSdkType { + /** + * The [FirebaseDataConnect] class is used directly in an application, rather than using the + * code generation done by the Firebase Data Connect toolkit. + */ + Base, + + /** + * The [FirebaseDataConnect] class is used by code generated by the Firebase Data Connect + * toolkit. + */ + Generated, + } + + /** + * The companion object for [FirebaseDataConnect], which provides extension methods and properties + * that may be accessed qualified by the class, rather than an instance of the class. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [Companion] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + */ + public companion object +} + +/** + * Returns the instance of [FirebaseDataConnect] associated with the given [FirebaseApp] and + * [ConnectorConfig], creating the [FirebaseDataConnect] instance if necessary. + * + * The instances of [FirebaseDataConnect] are keyed from the given [FirebaseApp], using the identity + * comparison operator `===`, and the given [ConnectorConfig], using the equivalence operator `==`. + * That is, the first invocation of this method with a given [FirebaseApp] and [ConnectorConfig] + * will create and return a new [FirebaseDataConnect] instance that is associated with those + * objects. A subsequent invocation with the same [FirebaseApp] object and an equal + * [ConnectorConfig] will return the same [FirebaseDataConnect] instance that was returned from the + * previous invocation. + * + * If a new [FirebaseDataConnect] instance is created, it will use the given [DataConnectSettings]. + * If an existing instance will be returned, then the given (or default) [DataConnectSettings] must + * be equal to the [FirebaseDataConnect.settings] of the instance about to be returned; otherwise, + * an exception is thrown. + * + * @param app The [FirebaseApp] instance with which the returned object is associated. + * @param config The [ConnectorConfig] with which the returned object is associated. + * @param settings The [DataConnectSettings] for the returned object to use. + * @return The [FirebaseDataConnect] instance associated with the given [FirebaseApp] and + * [ConnectorConfig], using the given [DataConnectSettings]. + */ +@SuppressLint("FirebaseUseExplicitDependencies") +public fun FirebaseDataConnect.Companion.getInstance( + app: FirebaseApp, + config: ConnectorConfig, + settings: DataConnectSettings = DataConnectSettings(), +): FirebaseDataConnect = + app.get(FirebaseDataConnectFactory::class.java).get(config = config, settings = settings) + +/** + * Returns the instance of [FirebaseDataConnect] associated with the default [FirebaseApp] and the + * given [ConnectorConfig], creating the [FirebaseDataConnect] instance if necessary. + * + * This method is a shorthand for calling `FirebaseDataConnect.getInstance(Firebase.app, config)` or + * `FirebaseDataConnect.getInstance(Firebase.app, config, settings)`. See the documentation of that + * method for full details. + * + * @param config The [ConnectorConfig] with which the returned object is associated. + * @param settings The [DataConnectSettings] for the returned object to use. + * @return The [FirebaseDataConnect] instance associated with the default [FirebaseApp] and the + * given [ConnectorConfig], using the given [DataConnectSettings]. + */ +public fun FirebaseDataConnect.Companion.getInstance( + config: ConnectorConfig, + settings: DataConnectSettings = DataConnectSettings() +): FirebaseDataConnect = getInstance(app = Firebase.app, config = config, settings = settings) + +/** + * The log level used by all [FirebaseDataConnect] instances. + * + * The default log level is [LogLevel.WARN]. Setting this to [LogLevel.DEBUG] will enable debug + * logging, which is especially useful when reporting issues to Google or investigating problems + * yourself. Setting it to [LogLevel.NONE] will disable all logging. + */ +public var FirebaseDataConnect.Companion.logLevel: LogLevel by LoggerGlobals::logLevel diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/LogLevel.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/LogLevel.kt new file mode 100644 index 00000000000..7aeaa1c67eb --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/LogLevel.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** + * The log levels supported by [FirebaseDataConnect]. + * + * @see FirebaseDataConnect.Companion.logLevel + */ +public enum class LogLevel { + + /** Log all messages, including detailed debug logs. */ + DEBUG, + + /** Only log warnings and errors; this is the default log level. */ + WARN, + + /** Do not log anything. */ + NONE, +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/MutationRef.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/MutationRef.kt new file mode 100644 index 00000000000..7299c381bdf --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/MutationRef.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** + * A specialization of [OperationRef] for _mutation_ operations. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [MutationRef] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [MutationRef] interface is _not_ stable for inheritance in third-party libraries, as new + * methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface MutationRef : OperationRef { + override suspend fun execute(): MutationResult +} + +/** + * A specialization of [OperationResult] for [MutationRef]. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [MutationResult] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [MutationResult] interface is _not_ stable for inheritance in third-party libraries, as new + * methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface MutationResult : OperationResult { + override val ref: MutationRef +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OperationRef.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OperationRef.kt new file mode 100644 index 00000000000..ac624ed0553 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OperationRef.kt @@ -0,0 +1,270 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +/** + * Information about a Firebase Data Connect "operation" (i.e. a query or mutation). + * + * [OperationRef] has two inheritors: [QueryRef] for queries and [MutationRef] for mutations. + * [OperationRef] merely serves to provide a common interface for the parts of queries and mutations + * that are shared. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [OperationRef] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [OperationRef] interface is _not_ stable for inheritance in third-party libraries, as new + * methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface OperationRef { + + /** The [FirebaseDataConnect] with which this object is associated. */ + public val dataConnect: FirebaseDataConnect + + /** + * The name of the operation, as defined in GraphQL. + * + * For example, a query defined as + * + * ``` + * query GetPersonById($id: UUID!) { person(id: $id) { name age } } + * ``` + * + * would have the operation name `"GetPersonById"` and a mutation defined as + * + * ``` + * mutation InsertPerson($name: String!, $age: Int) {...} + * ``` + * + * would have the operation name `"InsertPerson"` + */ + public val operationName: String + + /** + * The variables for the operation. + * + * The variables will be serialized using [variablesSerializer] and must produce a map whose keys + * are strings whose values are the names of the variables as defined in GraphQL, and whose values + * are the corresponding values. + * + * For example, a query defined as + * + * ``` + * query GetPersonById($id: UUID!) { person(id: $id) { name age } } + * ``` + * + * would have a variable named `"id"` whose value is a [java.util.UUID] instance and a mutation + * defined as + * + * ``` + * mutation InsertPerson($name: String!, $age: Int) {...} + * ``` + * + * would have two variables named `"name"` and `"age"` whose values are [String] and [Int?] + * values, respectively. + */ + public val variables: Variables + + /** + * The deserializer to use to deserialize the response data for this operation. + * + * Typically, the deserializer will be generated by Kotlin's serialization plugin for a class + * annotated with [kotlinx.serialization.Serializable]. + * + * For example, a query defined as + * + * ``` + * query GetPersonById($id: UUID!) { person(id: $id) { name age } } + * ``` + * + * could define its data class could as follows: + * + * ``` + * @Serializable + * data class GetPersonByIdData(val person: Person?) { + * @Serializable + * data class Person(val name: String, val age: Int?) + * } + * ``` + * + * and the deserializer could be retrieved by calling [kotlinx.serialization.serializer] as + * follows: + * + * ``` + * serializer() + * ``` + */ + public val dataDeserializer: DeserializationStrategy + + /** + * The serializer to use to serialize the variables for this operation. + * + * Typically, the serializer will be generated by Kotlin's serialization plugin for a class + * annotated with [kotlinx.serialization.Serializable]. + * + * For example, a mutation defined as + * + * ``` + * mutation InsertPerson($name: String!, $age: Int) {...} + * ``` + * + * could define its variables class could as follows: + * + * ``` + * @Serializable + * data class InsertPersonVariables(val name: String, val age: Int?) + * ``` + * + * and the serializer could be retrieved by calling [kotlinx.serialization.serializer] as follows: + * + * ``` + * serializer() + * ``` + */ + public val variablesSerializer: SerializationStrategy + + /** + * The [FirebaseDataConnect.CallerSdkType] that will be associated with all operations performed + * by this object for analytics purposes. + */ + public val callerSdkType: FirebaseDataConnect.CallerSdkType + + /** + * A [SerializersModule] to use when encoding the variables using [variablesSerializer]. May be + * `null`, to not use a [SerializersModule]. + */ + public val variablesSerializersModule: SerializersModule? + + /** + * A [SerializersModule] to use when decoding the response data using [dataDeserializer]. May be + * `null`, to not use a [SerializersModule]. + */ + public val dataSerializersModule: SerializersModule? + + /** + * Executes this operation and returns the result. + * + * An exception is thrown if the operation fails for any reason, including + * - The [FirebaseDataConnect] object has been closed. + * - The Firebase Data Connect server is unreachable. + * - Authentication with the Firebase Data Connect server fails. + * - The variables are rejected by the Firebase Data Connect server. + * - The data response sent by the Firebase Data Connect server cannot be deserialized. + */ + public suspend fun execute(): OperationResult + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of the same implementation of + * [OperationRef] whose public properties compare equal using the `==` operator to the + * corresponding properties of this object. + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String +} + +/** + * The result of a successful execution of an [OperationRef]. + * + * Typically, one of the inheritors of [OperationResult] is used, namely [QueryResult] for queries + * and [MutationResult] for mutations. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [OperationResult] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [OperationResult] interface is _not_ stable for inheritance in third-party libraries, as new + * methods might be added to this interface or contracts of the existing methods can be changed. + * + * @see OperationRef.execute + */ +public interface OperationResult { + /** The operation that produced this result. */ + public val ref: OperationRef + + /** The response data for the operation. */ + public val data: Data + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of the same implementation of + * [OperationResult] whose public properties compare equal using the `==` operator to the + * corresponding properties of this object. + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OptionalVariable.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OptionalVariable.kt new file mode 100644 index 00000000000..7662fa91acb --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OptionalVariable.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * An optional variable to a query or a mutation. + * + * The typical use case of this class is as a property of a class used as the variables of a query + * or mutation ([OperationRef.variables]). This allows omitting a variable altogether from the + * request, in the case of [OptionalVariable.Undefined], allowing the variable to take on its + * default value as defined in the GraphQL schema or operation, or an explicit value in the case of + * [OptionalVariable.Value], which may be `null` if the type parameter is nullable. + * + * Here is an example of such a variables class: + * + * ``` + * @Serializable + * data class UpdatePersonVariables( + * val key: PersonKey, + * val name: OptionalVariable, + * val age: OptionalVariable, + * ) + * ``` + * + * with this "variables" class, to clear a person's age but not modify their name, the instance + * could be created as follows + * ``` + * val variables = UpdatePersonVariables( + * key=key, + * name=OptionalVariable.Undefined, + * age=OptionalVariable.Value(42), + * ) + * ``` + */ +@Serializable(with = OptionalVariable.Serializer::class) +public sealed interface OptionalVariable { + + /** + * Returns the value encapsulated by this object if the runtime type is [Value], or `null` if this + * object is [Undefined]. + */ + public fun valueOrNull(): T? + + /** + * Returns the value encapsulated by this object if the runtime type is [Value], or throws an + * exception if this object is [Undefined]. + */ + public fun valueOrThrow(): T + + /** + * An implementation of [OptionalVariable] representing an "undefined" value. + * + * This value will be excluded entirely from the serial form. + */ + public object Undefined : OptionalVariable { + + /** Unconditionally returns `null`. */ + override fun valueOrNull(): Nothing? = null + + /** Unconditionally throws an exception. */ + override fun valueOrThrow(): Nothing = throw UndefinedValueException() + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + * + * @return a string representation of this object. + */ + override fun toString(): String = "undefined" + + private class UndefinedValueException : + IllegalStateException("Undefined does not have a value") + } + + /** + * An implementation of [OptionalVariable] representing a "defined" value. + * + * This value will be _included_ in the serial form, even if the value is `null`. + * + * @property value the value encapsulated by this [OptionalVariable]. + */ + public class Value(public val value: T) : OptionalVariable { + + /** Returns the value encapsulated by this [OptionalVariable], which _may_ be `null`. */ + override fun valueOrNull(): T = value + + /** + * Returns the value encapsulated by this [OptionalVariable], which _may_ be `null`, but never + * throws an exception. + */ + override fun valueOrThrow(): T = value + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of [Value] whose encapsulated + * value compares equal to this object's encapsulated value using the `==` operator. + */ + override fun equals(other: Any?): Boolean = other is Value<*> && value == other.value + + /** + * Returns the hash code of the encapsulated value, or `0` if the encapsulated value is `null`. + */ + override fun hashCode(): Int = value?.hashCode() ?: 0 + + /** + * Returns the [Object.toString()] result of the encapsulated value, or `"null"` if the + * encapsulated value is `null`. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at + * any time. Therefore, the only recommended usage of the returned string is debugging and/or + * logging. Namely, parsing the returned string or storing the returned string in non-volatile + * storage should generally be avoided in order to be robust in case that the string + * representation changes. + */ + override fun toString(): String = value?.toString() ?: "null" + } + + /** + * The [KSerializer] implementation for [OptionalVariable]. + * + * Note that this serializer _only_ supports [serialize], and [deserialize] unconditionally throws + * an exception. + * + * @param elementSerializer The [KSerializer] to use to serialize the encapsulated value. + */ + public class Serializer(private val elementSerializer: KSerializer) : + KSerializer> { + + override val descriptor: SerialDescriptor = elementSerializer.descriptor + + /** Unconditionally throws [UnsupportedOperationException]. */ + override fun deserialize(decoder: Decoder): OptionalVariable = + throw UnsupportedOperationException("OptionalVariableSerializer does not support decoding") + + /** + * Serializes the given [OptionalVariable] to the given encoder. + * + * This method does nothing if the given [OptionalVariable] is [Undefined]; otherwise, it + * serializes the encapsulated value in the given [Value] using the serializer given to this + * object's constructor. + */ + override fun serialize(encoder: Encoder, value: OptionalVariable) { + when (value) { + is OptionalVariable.Undefined -> { + /* nothing to do */ + } + is OptionalVariable.Value -> elementSerializer.serialize(encoder, value.value) + } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QueryRef.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QueryRef.kt new file mode 100644 index 00000000000..440c89f6cc6 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QueryRef.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +/** + * A specialization of [OperationRef] for _query_ operations. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [QueryRef] are thread-safe and may be safely called and/or accessed + * concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [QueryRef] interface is _not_ stable for inheritance in third-party libraries, as new methods + * might be added to this interface or contracts of the existing methods can be changed. + */ +public interface QueryRef : OperationRef { + override suspend fun execute(): QueryResult + + /** + * Subscribes to a query to be notified of updates to the query's data when the query is executed. + * + * At this time the notifications are _not_ realtime, and are _not_ pushed from the server. + * Instead, the notifications are sent whenever the query is explicitly executed by calling + * [QueryRef.execute]. + * + * @return an object that can be used to subscribe to query results. + */ + public fun subscribe(): QuerySubscription +} + +/** + * A specialization of [OperationResult] for [QueryRef]. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [QueryResult] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [QueryResult] interface is _not_ stable for inheritance in third-party libraries, as new + * methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface QueryResult : OperationResult { + override val ref: QueryRef +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QuerySubscription.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QuerySubscription.kt new file mode 100644 index 00000000000..f18f3bf125f --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QuerySubscription.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import kotlinx.coroutines.flow.* + +/** + * A facility to subscribe to a query to be notified of updates to the query's data when the query + * is executed. + * + * ### Notifications are _not_ Realtime + * + * At this time the notifications are _not_ realtime, and are _not_ pushed from the server. Instead, + * the notifications are sent whenever the query is explicitly executed by calling + * [QueryRef.execute]. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [QuerySubscription] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [QuerySubscription] interface is _not_ stable for inheritance in third-party libraries, as + * new methods might be added to this interface or contracts of the existing methods can be changed. + */ +public interface QuerySubscription { + + /** The query whose results this object subscribes. */ + public val query: QueryRef + + /** + * A cold flow that collects the query results as they become available. + * + * At this time the updates are _not_ realtime, and are _not_ pushed from the server. Instead, + * updates are sent whenever the query is explicitly executed by calling [QueryRef.execute]. + */ + public val flow: Flow> + + /** + * Compares this object with another object for equality, using the `===` operator. + * + * The implementation of this method simply uses referential equality. That is, two instances of + * [QuerySubscription] compare equal using this method if, and only if, they refer to the same + * object, as determined by the `===` operator. + * + * @param other The object to compare to this for equality. + * @return `other === this` + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * @return the hash code for this object. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String +} + +/** + * The result of a query's execution, as notified to a [QuerySubscription]. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [QuerySubscriptionResult] are thread-safe and may be safely called + * and/or accessed concurrently from multiple threads and/or coroutines. + * + * ### Not Stable for Inheritance + * + * The [QuerySubscriptionResult] interface is _not_ stable for inheritance in third-party libraries, + * as new methods might be added to this interface or contracts of the existing methods can be + * changed. + */ +public interface QuerySubscriptionResult { + + /** The query that was executed, whose result is captured in this object. */ + public val query: QueryRef + + /** + * The result of the query execution: a successful result if the query was executed successfully, + * or a failure if the query's execution failed. + */ + public val result: Result> + + /** + * Compares this object with another object for equality. + * + * @param other The object to compare to this for equality. + * @return true if, and only if, the other object is an instance of the same implementation of + * [QuerySubscriptionResult] whose public properties compare equal using the `==` operator to the + * corresponding properties of this object. + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * The hash code is _not_ guaranteed to be stable across application restarts. + * + * @return the hash code for this object, that incorporates the values of this object's public + * properties. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAppCheck.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAppCheck.kt new file mode 100644 index 00000000000..176aba53832 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAppCheck.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.annotations.DeferredApi +import com.google.firebase.appcheck.AppCheckTokenResult +import com.google.firebase.appcheck.interop.AppCheckTokenListener +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.dataconnect.core.Globals.toScrubbedAccessToken +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.tasks.await + +internal class DataConnectAppCheck( + deferredAppCheckTokenProvider: com.google.firebase.inject.Deferred, + parentCoroutineScope: CoroutineScope, + blockingDispatcher: CoroutineDispatcher, + logger: Logger, +) : + DataConnectCredentialsTokenManager( + deferredProvider = deferredAppCheckTokenProvider, + parentCoroutineScope = parentCoroutineScope, + blockingDispatcher = blockingDispatcher, + logger = logger, + ) { + override fun newTokenListener(): AppCheckTokenListener = AppCheckTokenListenerImpl(logger) + + @DeferredApi + override fun addTokenListener( + provider: InteropAppCheckTokenProvider, + listener: AppCheckTokenListener + ) = provider.addAppCheckTokenListener(listener) + + override fun removeTokenListener( + provider: InteropAppCheckTokenProvider, + listener: AppCheckTokenListener + ) = provider.removeAppCheckTokenListener(listener) + + override suspend fun getToken(provider: InteropAppCheckTokenProvider, forceRefresh: Boolean) = + provider.getToken(forceRefresh).await().let { GetTokenResult(it.token) } + + private class AppCheckTokenListenerImpl(private val logger: Logger) : AppCheckTokenListener { + override fun onAppCheckTokenChanged(tokenResult: AppCheckTokenResult) { + logger.debug { "onAppCheckTokenChanged(token=${tokenResult.token.toScrubbedAccessToken()})" } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAuth.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAuth.kt new file mode 100644 index 00000000000..1efd00af83e --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAuth.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.annotations.DeferredApi +import com.google.firebase.auth.internal.IdTokenListener +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.dataconnect.core.Globals.toScrubbedAccessToken +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.internal.InternalTokenResult +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.tasks.await + +internal class DataConnectAuth( + deferredAuthProvider: com.google.firebase.inject.Deferred, + parentCoroutineScope: CoroutineScope, + blockingDispatcher: CoroutineDispatcher, + logger: Logger, +) : + DataConnectCredentialsTokenManager( + deferredProvider = deferredAuthProvider, + parentCoroutineScope = parentCoroutineScope, + blockingDispatcher = blockingDispatcher, + logger = logger, + ) { + override fun newTokenListener(): IdTokenListener = IdTokenListenerImpl(logger) + + @DeferredApi + override fun addTokenListener(provider: InternalAuthProvider, listener: IdTokenListener) = + provider.addIdTokenListener(listener) + + override fun removeTokenListener(provider: InternalAuthProvider, listener: IdTokenListener) = + provider.removeIdTokenListener(listener) + + override suspend fun getToken(provider: InternalAuthProvider, forceRefresh: Boolean) = + provider.getAccessToken(forceRefresh).await().let { GetTokenResult(it.token) } + + private class IdTokenListenerImpl(private val logger: Logger) : IdTokenListener { + override fun onIdTokenChanged(tokenResult: InternalTokenResult) { + logger.debug { "onIdTokenChanged(token=${tokenResult.token?.toScrubbedAccessToken()})" } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt new file mode 100644 index 00000000000..74c80472e52 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt @@ -0,0 +1,511 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.annotations.DeferredApi +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.core.Globals.toScrubbedAccessToken +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.util.SequencedReference +import com.google.firebase.dataconnect.util.SequencedReference.Companion.nextSequenceNumber +import com.google.firebase.inject.Deferred.DeferredHandler +import com.google.firebase.inject.Provider +import com.google.firebase.internal.api.FirebaseNoSignedInUserException +import com.google.firebase.util.nextAlphanumericString +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.coroutineContext +import kotlin.random.Random +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + +/** Base class that shares logic for managing the Auth token and AppCheck token. */ +internal sealed class DataConnectCredentialsTokenManager( + private val deferredProvider: com.google.firebase.inject.Deferred, + parentCoroutineScope: CoroutineScope, + private val blockingDispatcher: CoroutineDispatcher, + protected val logger: Logger, +) { + val instanceId: String + get() = logger.nameWithId + + @Suppress("LeakingThis") private val weakThis = WeakReference(this) + + private val coroutineScope = + CoroutineScope( + parentCoroutineScope.coroutineContext + + SupervisorJob(parentCoroutineScope.coroutineContext[Job]) + + CoroutineName(instanceId) + + CoroutineExceptionHandler { context, throwable -> + logger.warn(throwable) { + "uncaught exception from a coroutine named ${context[CoroutineName]}: $throwable" + } + } + ) + + private interface ProviderListenerPair { + val provider: T? + val tokenListener: L + } + + private sealed interface State { + + /** State indicating that [initialize] has not yet been invoked. */ + object Uninitialized : State + + /** State indicating that [close] has been invoked. */ + object Closed : State + + /** + * State indicating that [initialize] has been invoked and there is no outstanding "get token" + * request. + */ + class Ready( + + /** + * The [InternalAuthProvider] or [InteropAppCheckTokenProvider]; may be null if the deferred + * has not yet given us a provider. + */ + override val provider: T?, + + /** The token listener that is, or will be, registered with [provider]. */ + override val tokenListener: L, + + /** The value to specify for `forceRefresh` on the next invocation of [getToken]. */ + val forceTokenRefresh: Boolean + ) : State, ProviderListenerPair + + /** + * State indicating that [initialize] has been invoked and there _is_ an outstanding "get token" + * request. + */ + class Active( + + /** + * The [InternalAuthProvider] or [InteropAppCheckTokenProvider] that is performing the "get + * token" request. + */ + override val provider: T, + + /** The token listener that is registered with [provider]. */ + override val tokenListener: L, + + /** The job that is performing the "get token" request. */ + val job: Deferred>> + ) : State, ProviderListenerPair + } + + /** + * The current state of this object. The value should only be changed in a compare-and-swap loop + * in order to be thread-safe. Such a loop should call `yield()` on each iteration to allow other + * coroutines to run on the thread. + */ + private val state = AtomicReference>(State.Uninitialized) + + /** + * Creates and returns a new "token listener" that will be registered with the provider returned + * from the [deferredProvider] specified to the constructor. + * + * @see addTokenListener + * @see removeTokenListener + */ + protected abstract fun newTokenListener(): L + + /** + * Adds the given listener to the given provider. + * + * @see removeTokenListener + */ + @DeferredApi protected abstract fun addTokenListener(provider: T, listener: L) + + /** + * Removes the given listener from the given provider. + * + * @see addTokenListener + */ + protected abstract fun removeTokenListener(provider: T, listener: L) + + /** + * Starts an asynchronous task to get a new access token from the given provider, forcing a token + * refresh if and only if `forceRefresh` is true. + */ + protected abstract suspend fun getToken(provider: T, forceRefresh: Boolean): GetTokenResult + + /** + * Initializes this object, acquiring resources and registering necessary listeners. + * + * This method must be called exactly once before any other methods on this object. The only + * exception is that [close] may be invoked without having invoked this method. + * + * @throws IllegalStateException if invoked more than once or after [close]. + * + * @see close + */ + fun initialize() { + val newState = + State.Ready(provider = null, tokenListener = newTokenListener(), forceTokenRefresh = false) + + while (true) { + val oldState = state.get() + if (oldState != State.Uninitialized) { + throw IllegalStateException( + if (oldState == State.Closed) { + "initialize() may not be called after close()" + } else { + "initialize() has already been called" + } + ) + } + + if (state.compareAndSet(oldState, newState)) { + break + } + } + + // Call `whenAvailable()` on a non-main thread because it accesses SharedPreferences, which + // performs disk i/o, violating the StrictMode policy android.os.strictmode.DiskReadViolation. + val coroutineName = CoroutineName("k6rwgqg9gh $instanceId whenAvailable") + coroutineScope.launch(coroutineName + blockingDispatcher) { + deferredProvider.whenAvailable(DeferredProviderHandlerImpl(weakThis, newState.tokenListener)) + } + } + + /** + * Closes this object, releasing its resources, unregistering any registered listeners, and + * cancelling any in-flight token requests. + * + * This method is re-entrant; that is, it may be invoked multiple times; however, only one such + * invocation will actually do the work of closing. If invoked concurrently, invocations other + * than the one that actually does the work of closing may return _before_ the work of closing has + * actually completed. In other words, this method does _not_ block waiting for the work of + * closing to be completed by another thread. + */ + fun close() { + logger.debug { "close()" } + weakThis.clear() + coroutineScope.cancel() + setClosedState() + } + + // This function must ONLY be called from close(). + private fun setClosedState() { + while (true) { + val oldState = state.get() + val providerListenerPair: ProviderListenerPair? = + when (oldState) { + is State.Closed -> return + is State.Uninitialized -> null + is State.Ready -> oldState + is State.Active -> oldState + } + + if (state.compareAndSet(oldState, State.Closed)) { + providerListenerPair?.run { + provider?.let { provider -> + runIgnoringFirebaseAppDeleted { removeTokenListener(provider, tokenListener) } + } + } + return + } + } + } + + /** + * Sets a flag to force-refresh the token upon the next call to [getToken]. + * + * If [close] has been called, this method does nothing. + * + * @throws IllegalStateException if [initialize] has not been called. + */ + suspend fun forceRefresh() { + logger.debug { "forceRefresh()" } + while (true) { + val oldState = state.get() + val providerListenerPair: ProviderListenerPair = + when (oldState) { + is State.Uninitialized -> + throw IllegalStateException("forceRefresh() cannot be called before initialize()") + is State.Closed -> return + is State.Ready -> oldState + is State.Active -> { + val message = "needs token refresh (wgrwbrvjxt)" + oldState.job.cancel(message, ForceRefresh(message)) + oldState + } + } + + val newState = + State.Ready( + providerListenerPair.provider, + providerListenerPair.tokenListener, + forceTokenRefresh = true + ) + if (state.compareAndSet(oldState, newState)) { + break + } + + yield() + } + } + + private fun newActiveState( + invocationId: String, + provider: T, + tokenListener: L, + forceRefresh: Boolean + ): State.Active { + val coroutineName = + CoroutineName( + "$instanceId 535gmcvv5a $invocationId getToken(" + + "provider=${provider}, forceRefresh=$forceRefresh)" + ) + val job = + coroutineScope.async(coroutineName, CoroutineStart.LAZY) { + val sequenceNumber = nextSequenceNumber() + logger.debug { "$invocationId getToken(forceRefresh=$forceRefresh)" } + val result = runCatching { getToken(provider, forceRefresh) } + SequencedReference(sequenceNumber, result) + } + return State.Active(provider, tokenListener, job) + } + + /** + * Gets the access token, force-refreshing it if [forceRefresh] has been called. + * + * @throws IllegalStateException if [initialize] has not been called. + * @throws DataConnectException if [close] has not been called or is called while the operation is + * in progress. + */ + suspend fun getToken(requestId: String): String? { + val invocationId = "gat" + Random.nextAlphanumericString(length = 8) + logger.debug { "$invocationId getToken(requestId=$requestId)" } + while (true) { + val attemptSequenceNumber = nextSequenceNumber() + val oldState = state.get() + + val newState: State.Active = + when (oldState) { + is State.Uninitialized -> + throw IllegalStateException("getToken() cannot be called before initialize()") + is State.Closed -> { + logger.debug { + "$invocationId getToken() throws CredentialsTokenManagerClosedException" + + " because the DataConnectCredentialsTokenManager instance has been closed" + } + throw CredentialsTokenManagerClosedException(this) + } + is State.Ready -> { + if (oldState.provider === null) { + logger.debug { + "$invocationId getToken() returns null" + + " (token provider is not (yet?) available)" + } + return null + } + newActiveState( + invocationId, + oldState.provider, + oldState.tokenListener, + oldState.forceTokenRefresh + ) + } + is State.Active -> { + if ( + oldState.job.isCompleted && + !oldState.job.isCancelled && + oldState.job.await().sequenceNumber < attemptSequenceNumber + ) { + newActiveState( + invocationId, + oldState.provider, + oldState.tokenListener, + forceRefresh = false + ) + } else { + oldState + } + } + } + + if (oldState !== newState) { + if (!state.compareAndSet(oldState, newState)) { + continue + } + logger.debug { + "$invocationId getToken() starts a new coroutine to get the token" + + " (oldState=${oldState::class.simpleName})" + } + } + + val jobResult = newState.job.runCatching { await() } + + // Ensure that any exception checking below is due to an exception that happened in the + // coroutine that called getToken(), not from the calling coroutine being cancelled. + coroutineContext.ensureActive() + + val sequencedResult = jobResult.getOrNull() + if (sequencedResult !== null && sequencedResult.sequenceNumber < attemptSequenceNumber) { + logger.debug { "$invocationId getToken() got an old result; retrying" } + continue + } + + val exception = jobResult.exceptionOrNull() ?: jobResult.getOrNull()?.ref?.exceptionOrNull() + if (exception !== null) { + val retryException = exception.getRetryIndicator() + if (retryException !== null) { + logger.debug { "$invocationId getToken() retrying due to ${retryException.message}" } + continue + } else if (exception is FirebaseNoSignedInUserException) { + logger.debug { + "$invocationId getToken() returns null" + " (FirebaseAuth reports no signed-in user)" + } + return null + } else if (exception is CancellationException) { + logger.warn(exception) { + "$invocationId getToken() throws GetTokenCancelledException," + + " likely due to DataConnectCredentialsTokenManager.close() being called" + } + throw GetTokenCancelledException(exception) + } else { + logger.warn(exception) { "$invocationId getToken() failed unexpectedly: $exception" } + throw exception + } + } + + val accessToken = sequencedResult!!.ref.getOrThrow().token + logger.debug { + "$invocationId getToken() returns retrieved token: ${accessToken?.toScrubbedAccessToken()}" + } + return accessToken + } + } + + private sealed class GetTokenRetry(message: String) : Exception(message) + private class ForceRefresh(message: String) : GetTokenRetry(message) + private class NewProvider(message: String) : GetTokenRetry(message) + + @DeferredApi + private fun onProviderAvailable(newProvider: T, tokenListener: L) { + logger.debug { "onProviderAvailable(newProvider=$newProvider)" } + runIgnoringFirebaseAppDeleted { addTokenListener(newProvider, tokenListener) } + + while (true) { + val oldState = state.get() + val newState = + when (oldState) { + is State.Uninitialized -> + throw IllegalStateException( + "INTERNAL ERROR: onProviderAvailable() called before initialize()" + ) + is State.Closed -> { + logger.debug { + "onProviderAvailable(newProvider=$newProvider)" + + " unregistering token listener that was just added" + } + runIgnoringFirebaseAppDeleted { removeTokenListener(newProvider, tokenListener) } + break + } + is State.Ready -> + State.Ready(newProvider, oldState.tokenListener, oldState.forceTokenRefresh) + is State.Active -> { + val newProviderClassName = newProvider::class.qualifiedName + val message = "a new provider $newProviderClassName is available (symhxtmazy)" + oldState.job.cancel(message, NewProvider(message)) + State.Ready(newProvider, tokenListener, forceTokenRefresh = false) + } + } + + if (state.compareAndSet(oldState, newState)) { + break + } + } + } + + /** + * An implementation of [DeferredHandler] to be registered with the [Deferred] given to the + * constructor. + * + * This separate class is used (as opposed to using a more-convenient lambda) to avoid holding a + * strong reference to the [DataConnectCredentialsTokenManager] instance indefinitely, in the case + * that the callback never occurs. + */ + private class DeferredProviderHandlerImpl( + private val weakCredentialsTokenManagerRef: + WeakReference>, + private val tokenListener: L, + ) : DeferredHandler { + override fun handle(provider: Provider) { + weakCredentialsTokenManagerRef.get()?.onProviderAvailable(provider.get(), tokenListener) + } + } + + private class CredentialsTokenManagerClosedException( + tokenProvider: DataConnectCredentialsTokenManager<*, *> + ) : + DataConnectException( + "DataConnectCredentialsTokenManager ${tokenProvider.instanceId} was closed" + ) + + private class GetTokenCancelledException(cause: Throwable) : + DataConnectException("getToken() was cancelled, likely by close()", cause) + + // Work around a race condition where addIdTokenListener() and removeIdTokenListener() throw if + // the FirebaseApp is deleted during or before its invocation. + private fun runIgnoringFirebaseAppDeleted(block: () -> Unit) { + try { + block() + } catch (e: IllegalStateException) { + if (e.message == "FirebaseApp was deleted") { + logger.warn(e) { "ignoring exception: $e" } + } else { + throw e + } + } + } + + protected data class GetTokenResult(val token: String?) + + private companion object { + + fun Throwable.getRetryIndicator(): GetTokenRetry? { + var currentCause: Throwable? = this + while (true) { + if (currentCause === null) { + return null + } else if (currentCause is GetTokenRetry) { + return currentCause + } + currentCause = currentCause.cause ?: return null + } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt new file mode 100644 index 00000000000..b2d2270056b --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toMap +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import google.firebase.dataconnect.proto.GraphqlError +import google.firebase.dataconnect.proto.SourceLocation +import google.firebase.dataconnect.proto.executeMutationRequest +import google.firebase.dataconnect.proto.executeQueryRequest +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class DataConnectGrpcClient( + projectId: String, + connector: ConnectorConfig, + private val grpcRPCs: DataConnectGrpcRPCs, + private val dataConnectAuth: DataConnectAuth, + private val dataConnectAppCheck: DataConnectAppCheck, + private val logger: Logger, +) { + val instanceId: String + get() = logger.nameWithId + + private val requestName = + "projects/$projectId/" + + "locations/${connector.location}" + + "/services/${connector.serviceId}" + + "/connectors/${connector.connector}" + + data class OperationResult( + val data: Struct?, + val errors: List, + ) + + suspend fun executeQuery( + requestId: String, + operationName: String, + variables: Struct, + callerSdkType: FirebaseDataConnect.CallerSdkType, + ): OperationResult { + val request = executeQueryRequest { + this.name = requestName + this.operationName = operationName + this.variables = variables + } + + val response = + grpcRPCs.retryOnGrpcUnauthenticatedError(requestId, "executeQuery") { + executeQuery(requestId, request, callerSdkType) + } + + return OperationResult( + data = if (response.hasData()) response.data else null, + errors = response.errorsList.map { it.toDataConnectError() } + ) + } + + suspend fun executeMutation( + requestId: String, + operationName: String, + variables: Struct, + callerSdkType: FirebaseDataConnect.CallerSdkType, + ): OperationResult { + val request = executeMutationRequest { + this.name = requestName + this.operationName = operationName + this.variables = variables + } + + val response = + grpcRPCs.retryOnGrpcUnauthenticatedError(requestId, "executeMutation") { + executeMutation(requestId, request, callerSdkType) + } + + return OperationResult( + data = if (response.hasData()) response.data else null, + errors = response.errorsList.map { it.toDataConnectError() } + ) + } + + private suspend inline fun T.retryOnGrpcUnauthenticatedError( + requestId: String, + kotlinMethodName: String, + block: T.() -> R + ): R { + return try { + block() + } catch (e: StatusException) { + if (e.status.code != Status.UNAUTHENTICATED.code) { + throw e + } + logger.warn(e) { + "$kotlinMethodName() [rid=$requestId]" + + " retrying with fresh Auth and/or AppCheck tokens due to UNAUTHENTICATED error" + } + + // TODO(b/356877295) Only invalidate auth or appcheck tokens, but not both, to avoid + // spamming the appcheck attestation provider. + dataConnectAuth.forceRefresh() + dataConnectAppCheck.forceRefresh() + + block() + } + } +} + +/** + * Holder for "global" functions related to [DataConnectGrpcClient]. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates a + * DataConnectGrpcClientKit Java class with public visibility, which pollutes the public API. Using + * an "internal" object, instead, to gather together the top-level functions avoids this public API + * pollution. + */ +internal object DataConnectGrpcClientGlobals { + private fun ListValue.toPathSegment() = + valuesList.map { + when (val kind = it.kindCase) { + Value.KindCase.STRING_VALUE -> DataConnectError.PathSegment.Field(it.stringValue) + Value.KindCase.NUMBER_VALUE -> + DataConnectError.PathSegment.ListIndex(it.numberValue.toInt()) + else -> DataConnectError.PathSegment.Field("invalid PathSegment kind: $kind") + } + } + + private fun List.toSourceLocations(): List = + buildList { + this@toSourceLocations.forEach { + add(DataConnectError.SourceLocation(line = it.line, column = it.column)) + } + } + + fun GraphqlError.toDataConnectError() = + DataConnectError( + message = message, + path = path.toPathSegment(), + this.locationsList.toSourceLocations() + ) + + fun DataConnectGrpcClient.OperationResult.deserialize( + deserializer: DeserializationStrategy, + serializersModule: SerializersModule?, + ): T = + if (deserializer === DataConnectUntypedData) { + @Suppress("UNCHECKED_CAST") + DataConnectUntypedData(data?.toMap(), errors) as T + } else if (data === null) { + if (errors.isNotEmpty()) { + throw DataConnectException("operation failed: errors=$errors") + } else { + throw DataConnectException("no data included in result") + } + } else if (errors.isNotEmpty()) { + throw DataConnectException("operation failed: errors=$errors (data=$data)") + } else { + try { + decodeFromStruct(data, deserializer, serializersModule) + } catch (dataConnectException: DataConnectException) { + throw dataConnectException + } catch (throwable: Throwable) { + throw DataConnectException("decoding response data failed: $throwable", throwable) + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadata.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadata.kt new file mode 100644 index 00000000000..dd5ae849264 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadata.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.os.Build +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.BuildConfig +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.core.Globals.toScrubbedAccessToken +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.protobuf.Struct +import io.grpc.Metadata + +internal class DataConnectGrpcMetadata( + val dataConnectAuth: DataConnectAuth, + val dataConnectAppCheck: DataConnectAppCheck, + val connectorLocation: String, + val kotlinVersion: String, + val androidVersion: Int, + val dataConnectSdkVersion: String, + val grpcVersion: String, + val appId: String, + val parentLogger: Logger, +) { + private val logger = + Logger("DataConnectGrpcMetadata").apply { + debug { + "created by ${parentLogger.nameWithId} with" + + " dataConnectAuth=${dataConnectAuth.instanceId}" + + " connectorLocation=$connectorLocation" + + " kotlinVersion=$kotlinVersion" + + " androidVersion=$androidVersion" + + " dataConnectSdkVersion=$dataConnectSdkVersion" + + " grpcVersion=$grpcVersion" + + " appId=$appId" + } + } + val instanceId: String + get() = logger.nameWithId + + @Suppress("SpellCheckingInspection") + private val googRequestParamsHeaderValue = "location=${connectorLocation}&frontend=data" + + private fun googApiClientHeaderValue(callerSdkType: FirebaseDataConnect.CallerSdkType): String { + val components = buildList { + add("gl-kotlin/$kotlinVersion") + add("gl-android/$androidVersion") + add("fire/$dataConnectSdkVersion") + add("grpc/$grpcVersion") + + when (callerSdkType) { + FirebaseDataConnect.CallerSdkType.Base -> { + /* nothing to add for Base */ + } + FirebaseDataConnect.CallerSdkType.Generated -> { + add("kotlin/gen") + } + } + } + return components.joinToString(" ") + } + + suspend fun get(requestId: String, callerSdkType: FirebaseDataConnect.CallerSdkType): Metadata { + val authToken = dataConnectAuth.getToken(requestId) + val appCheckToken = dataConnectAppCheck.getToken(requestId) + return Metadata().also { + it.put(googRequestParamsHeader, googRequestParamsHeaderValue) + it.put(googApiClientHeader, googApiClientHeaderValue(callerSdkType)) + if (appId.isNotBlank()) { + it.put(gmpAppIdHeader, appId) + } + if (authToken !== null) { + it.put(firebaseAuthTokenHeader, authToken) + } + if (appCheckToken !== null) { + it.put(firebaseAppCheckTokenHeader, appCheckToken) + } + } + } + + companion object { + fun Metadata.toStructProto(): Struct = buildStructProto { + val keys: List> = run { + val keySet: MutableSet = keys().toMutableSet() + // Always explicitly include the auth header in the returned string, even if it is absent. + keySet.add(firebaseAuthTokenHeader.name()) + keySet.add(firebaseAppCheckTokenHeader.name()) + keySet.sorted().map { Metadata.Key.of(it, Metadata.ASCII_STRING_MARSHALLER) } + } + + for (key in keys) { + val values = getAll(key) + val scrubbedValues = + if (values === null) listOf(null) + else { + values.map { + when (key.name()) { + firebaseAuthTokenHeader.name() -> it.toScrubbedAccessToken() + firebaseAppCheckTokenHeader.name() -> it.toScrubbedAccessToken() + else -> it + } + } + } + + for (scrubbedValue in scrubbedValues) { + put(key.name(), scrubbedValue) + } + } + } + + private val firebaseAuthTokenHeader: Metadata.Key = + Metadata.Key.of("x-firebase-auth-token", Metadata.ASCII_STRING_MARSHALLER) + + private val firebaseAppCheckTokenHeader: Metadata.Key = + Metadata.Key.of("x-firebase-appcheck", Metadata.ASCII_STRING_MARSHALLER) + + @Suppress("SpellCheckingInspection") + private val googRequestParamsHeader: Metadata.Key = + Metadata.Key.of("x-goog-request-params", Metadata.ASCII_STRING_MARSHALLER) + + @Suppress("SpellCheckingInspection") + private val googApiClientHeader: Metadata.Key = + Metadata.Key.of("x-goog-api-client", Metadata.ASCII_STRING_MARSHALLER) + + @Suppress("SpellCheckingInspection") + private val gmpAppIdHeader: Metadata.Key = + Metadata.Key.of("x-firebase-gmpid", Metadata.ASCII_STRING_MARSHALLER) + + fun forSystemVersions( + firebaseApp: FirebaseApp, + dataConnectAuth: DataConnectAuth, + dataConnectAppCheck: DataConnectAppCheck, + connectorLocation: String, + parentLogger: Logger, + ): DataConnectGrpcMetadata = + DataConnectGrpcMetadata( + dataConnectAuth = dataConnectAuth, + dataConnectAppCheck = dataConnectAppCheck, + connectorLocation = connectorLocation, + kotlinVersion = "${KotlinVersion.CURRENT}", + androidVersion = Build.VERSION.SDK_INT, + dataConnectSdkVersion = BuildConfig.VERSION_NAME, + grpcVersion = "", // no way to get the grpc version at runtime, + appId = firebaseApp.options.applicationId, + parentLogger = parentLogger, + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCs.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCs.kt new file mode 100644 index 00000000000..c87e276d667 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCs.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.content.Context +import com.google.android.gms.security.ProviderInstaller +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.core.DataConnectGrpcMetadata.Companion.toStructProto +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString +import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto +import com.google.firebase.dataconnect.util.SuspendingLazy +import com.google.protobuf.Struct +import google.firebase.dataconnect.proto.ConnectorServiceGrpc +import google.firebase.dataconnect.proto.ConnectorServiceGrpcKt +import google.firebase.dataconnect.proto.EmulatorInfo +import google.firebase.dataconnect.proto.EmulatorIssuesResponse +import google.firebase.dataconnect.proto.EmulatorServiceGrpc +import google.firebase.dataconnect.proto.EmulatorServiceGrpcKt +import google.firebase.dataconnect.proto.ExecuteMutationRequest +import google.firebase.dataconnect.proto.ExecuteMutationResponse +import google.firebase.dataconnect.proto.ExecuteQueryRequest +import google.firebase.dataconnect.proto.ExecuteQueryResponse +import google.firebase.dataconnect.proto.GetEmulatorInfoRequest +import google.firebase.dataconnect.proto.StreamEmulatorIssuesRequest +import io.grpc.ManagedChannelBuilder +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import io.grpc.android.AndroidChannelBuilder +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class DataConnectGrpcRPCs( + context: Context, + host: String, + sslEnabled: Boolean, + private val blockingCoroutineDispatcher: CoroutineDispatcher, + private val grpcMetadata: DataConnectGrpcMetadata, + parentLogger: Logger, +) { + private val logger = + Logger("DataConnectGrpcRPCs").apply { + debug { + "created by ${parentLogger.nameWithId} with" + + " host=$host" + + " sslEnabled=$sslEnabled" + + " grpcMetadata=${grpcMetadata.instanceId}" + } + } + val instanceId: String + get() = logger.nameWithId + + private val mutex = Mutex() + private var closed = false + + // Use the non-main-thread CoroutineDispatcher to avoid blocking operations on the main thread. + private val lazyGrpcChannel = + SuspendingLazy(mutex = mutex, coroutineContext = blockingCoroutineDispatcher) { + check(!closed) { "DataConnectGrpcRPCs ${logger.nameWithId} instance has been closed" } + logger.debug { "Creating GRPC ManagedChannel for host=$host sslEnabled=$sslEnabled" } + + // Upgrade the Android security provider using Google Play Services. + // + // We need to upgrade the Security Provider before any network channels are initialized + // because okhttp maintains a list of supported providers that is initialized when the JVM + // first resolves the static dependencies of ManagedChannel. + // + // If initialization fails for any reason, then a warning is logged and the original, + // un-upgraded security provider is used. + try { + ProviderInstaller.installIfNeeded(context) + } catch (e: Exception) { + logger.warn(e) { "Failed to update ssl context" } + } + + val grpcChannel = + ManagedChannelBuilder.forTarget(host).let { + if (!sslEnabled) { + it.usePlaintext() + } + + // Ensure gRPC recovers from a dead connection. This is not typically necessary, as + // the OS will usually notify gRPC when a connection dies. But not always. This acts as a + // failsafe. + it.keepAliveTime(30, TimeUnit.SECONDS) + + it.executor(blockingCoroutineDispatcher.asExecutor()) + + // Wrap the `ManagedChannelBuilder` in an `AndroidChannelBuilder`. This allows the channel + // to respond more gracefully to network change events, such as switching from cellular to + // wifi. + AndroidChannelBuilder.usingBuilder(it).context(context).build() + } + + logger.debug { "Creating GRPC ManagedChannel for host=$host sslEnabled=$sslEnabled done" } + grpcChannel + } + + private val lazyGrpcStub = + SuspendingLazy(mutex) { + check(!closed) { "DataConnectGrpcRPCs ${logger.nameWithId} instance has been closed" } + ConnectorServiceGrpcKt.ConnectorServiceCoroutineStub(lazyGrpcChannel.getLocked()) + } + + private val lazyEmulatorGrpcStub = + SuspendingLazy(mutex) { + check(!closed) { "DataConnectGrpcRPCs ${logger.nameWithId} instance has been closed" } + EmulatorServiceGrpcKt.EmulatorServiceCoroutineStub(lazyGrpcChannel.getLocked()) + } + + suspend fun executeMutation( + requestId: String, + request: ExecuteMutationRequest, + callerSdkType: FirebaseDataConnect.CallerSdkType, + ): ExecuteMutationResponse { + val metadata = grpcMetadata.get(requestId, callerSdkType) + val kotlinMethodName = "executeMutation(${request.operationName})" + + logger.logGrpcSending( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + grpcMethod = ConnectorServiceGrpc.getExecuteMutationMethod(), + metadata = metadata, + request = request.toStructProto(), + requestTypeName = "ExecuteMutationRequest", + ) + + val result = lazyGrpcStub.get().runCatching { executeMutation(request, metadata) } + + result.onSuccess { + logger.logGrpcReceived( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + response = it.toStructProto(), + responseTypeName = "ExecuteMutationResponse", + ) + } + result.onFailure { + logger.logGrpcFailed( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + it, + ) + } + + return result.getOrThrow() + } + + suspend fun executeQuery( + requestId: String, + request: ExecuteQueryRequest, + callerSdkType: FirebaseDataConnect.CallerSdkType, + ): ExecuteQueryResponse { + val metadata = grpcMetadata.get(requestId, callerSdkType) + val kotlinMethodName = "executeQuery(${request.operationName})" + + logger.logGrpcSending( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + grpcMethod = ConnectorServiceGrpc.getExecuteQueryMethod(), + metadata = metadata, + request = request.toStructProto(), + requestTypeName = "ExecuteQueryRequest", + ) + + val result = lazyGrpcStub.get().runCatching { executeQuery(request, metadata) } + + result.onSuccess { + logger.logGrpcReceived( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + response = it.toStructProto(), + responseTypeName = "ExecuteQueryResponse", + ) + } + result.onFailure { + logger.logGrpcFailed( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + it, + ) + } + + return result.getOrThrow() + } + + suspend fun getEmulatorInfo(requestId: String): EmulatorInfo { + val request = GetEmulatorInfoRequest.getDefaultInstance() + val kotlinMethodName = "getEmulatorInfo()" + + logger.logGrpcStarting( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + grpcMethod = EmulatorServiceGrpc.getGetEmulatorInfoMethod(), + ) + + val result = lazyEmulatorGrpcStub.get().runCatching { getEmulatorInfo(request) } + + result.onSuccess { + logger.logGrpcReceived( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + response = it.toStructProto(), + responseTypeName = "EmulatorInfo", + ) + } + result.onFailure { + logger.logGrpcFailed( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + it, + ) + } + + return result.getOrThrow() + } + + suspend fun streamEmulatorIssues( + requestId: String, + serviceId: String + ): Flow { + val request = StreamEmulatorIssuesRequest.newBuilder().setServiceId(serviceId).build() + val kotlinMethodName = "streamEmulatorIssues(serviceId=$serviceId)" + + val flow = lazyEmulatorGrpcStub.get().streamEmulatorIssues(request) + + return flow + .onStart { + logger.logGrpcStarting( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + grpcMethod = EmulatorServiceGrpc.getStreamEmulatorIssuesMethod(), + ) + } + .onEach { response -> + logger.logGrpcReceived( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + response = response.toStructProto(), + responseTypeName = "EmulatorIssuesResponse" + ) + } + .onCompletion { exception -> + if (exception === null || exception is CancellationException) { + logger.logGrpcCompleted(requestId = requestId, kotlinMethodName = kotlinMethodName) + } else { + logger.logGrpcFailed( + requestId = requestId, + kotlinMethodName = kotlinMethodName, + throwable = exception, + ) + } + } + } + + suspend fun close() { + logger.debug { "close()" } + mutex.withLock { closed = true } + + val grpcChannel = lazyGrpcChannel.initializedValueOrNull ?: return + + // Avoid blocking the calling thread by running potentially-blocking code on the dispatcher + // given to the constructor, which should have similar semantics to [Dispatchers.IO]. + withContext(blockingCoroutineDispatcher) { + grpcChannel.shutdownNow() + grpcChannel.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS) + } + } + + private companion object { + fun Logger.logGrpcSending( + requestId: String, + kotlinMethodName: String, + grpcMethod: MethodDescriptor<*, *>, + metadata: Metadata, + request: Struct, + requestTypeName: String + ) = debug { + val struct = buildStructProto { + put("RPC", grpcMethod.fullMethodName) + put("Metadata", metadata.toStructProto()) + put(requestTypeName, request) + } + // Sort the keys in the output string to be more meaningful than alphabetical. + val keySortSelector: (String) -> String = { + when (it) { + "RPC" -> "AAAA" + "Metadata" -> "AAAB" + requestTypeName -> "AAAC" + else -> it + } + } + "$kotlinMethodName [rid=$requestId] sending: ${struct.toCompactString(keySortSelector)}" + } + + fun Logger.logGrpcStarting( + requestId: String, + kotlinMethodName: String, + grpcMethod: MethodDescriptor<*, *>, + ) = debug { "$kotlinMethodName [rid=$requestId] starting ${grpcMethod.fullMethodName}" } + + fun Logger.logGrpcCompleted( + requestId: String, + kotlinMethodName: String, + ) = debug { "$kotlinMethodName [rid=$requestId] completed" } + + fun Logger.logGrpcReceived( + requestId: String, + kotlinMethodName: String, + response: Struct, + responseTypeName: String + ) = debug { + val struct = buildStructProto { put(responseTypeName, response) } + "$kotlinMethodName [rid=$requestId] received: ${struct.toCompactString()}" + } + + fun Logger.logGrpcFailed( + requestId: String, + kotlinMethodName: String, + throwable: Throwable, + ) = warn(throwable) { "$kotlinMethodName [rid=$requestId] FAILED: $throwable" } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectFactory.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectFactory.kt new file mode 100644 index 00000000000..ef410549e7b --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectFactory.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.dataconnect.* +import com.google.firebase.inject.Deferred +import java.util.concurrent.Executor +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class FirebaseDataConnectFactory( + private val context: Context, + private val firebaseApp: FirebaseApp, + private val blockingExecutor: Executor, + private val nonBlockingExecutor: Executor, + private val deferredAuthProvider: Deferred, + private val deferredAppCheckProvider: Deferred, +) { + + init { + firebaseApp.addLifecycleEventListener { _, _ -> close() } + } + + private val lock = ReentrantLock() + private val instances = mutableMapOf() + private var closed = false + + fun get(config: ConnectorConfig, settings: DataConnectSettings?): FirebaseDataConnect { + val key = + config.run { + FirebaseDataConnectInstanceKey( + serviceId = serviceId, + location = location, + connector = connector + ) + } + + lock.withLock { + if (closed) { + throw IllegalStateException("FirebaseApp has been deleted") + } + + val cachedInstance = instances[key] + if (cachedInstance !== null) { + throwIfIncompatible(key, cachedInstance, settings) + return cachedInstance + } + + val newInstance = FirebaseDataConnect.newInstance(config, settings) + instances[key] = newInstance + return newInstance + } + } + + private fun FirebaseDataConnect.Companion.newInstance( + config: ConnectorConfig, + settings: DataConnectSettings? + ) = + FirebaseDataConnectImpl( + context = context, + app = firebaseApp, + projectId = firebaseApp.options.projectId ?: "", + config = config, + blockingExecutor = blockingExecutor, + nonBlockingExecutor = nonBlockingExecutor, + deferredAuthProvider = deferredAuthProvider, + deferredAppCheckProvider = deferredAppCheckProvider, + creator = this@FirebaseDataConnectFactory, + settings = settings ?: DataConnectSettings(), + ) + + fun remove(instance: FirebaseDataConnect) { + lock.withLock { + val keysForInstance = instances.entries.filter { it.value === instance }.map { it.key } + + when (keysForInstance.size) { + 0 -> {} + 1 -> instances.remove(keysForInstance[0]) + else -> + throw IllegalStateException( + "internal error: FirebaseDataConnect instance $instance " + + "maps to ${keysForInstance.size} keys, but expected at most 1: " + + keysForInstance.joinToString(", ") + ) + } + } + } + + private fun close() { + val instanceList = + lock.withLock { + closed = true + instances.values.toList() + } + + instanceList.forEach(FirebaseDataConnect::close) + + lock.withLock { + if (instances.isNotEmpty()) { + throw IllegalStateException( + "internal error: 'instances' contains ${instances.size} elements " + + "after calling close() on all FirebaseDataConnect instances, " + + "but expected 0" + ) + } + } + } + + private companion object { + private fun throwIfIncompatible( + key: FirebaseDataConnectInstanceKey, + instance: FirebaseDataConnect, + settings: DataConnectSettings? + ) { + val keyStr = key.run { "serviceId=$serviceId, location=$location, connector=$connector" } + if (settings !== null && instance.settings != settings) { + throw IllegalArgumentException( + "The settings of the FirebaseDataConnect instance with [$keyStr] is " + + "'${instance.settings}', which is different from the given settings: $settings; " + + "to get a FirebaseDataConnect with [$keyStr] but different settings, first call " + + "close() on the existing FirebaseDataConnect instance, then call getInstance() " + + "again with the desired settings. Alternately, call getInstance() with null " + + "settings to use whatever settings are configured in the existing " + + "FirebaseDataConnect instance." + ) + } + } + } +} + +private data class FirebaseDataConnectInstanceKey( + val connector: String, + val location: String, + val serviceId: String, +) { + override fun toString() = "serviceId=$serviceId, location=$location, connector=$connector" +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt new file mode 100644 index 00000000000..564cdf3b003 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt @@ -0,0 +1,444 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.FirebaseDataConnect.MutationRefOptionsBuilder +import com.google.firebase.dataconnect.FirebaseDataConnect.QueryRefOptionsBuilder +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.isDefaultHost +import com.google.firebase.dataconnect.querymgr.LiveQueries +import com.google.firebase.dataconnect.querymgr.LiveQuery +import com.google.firebase.dataconnect.querymgr.QueryManager +import com.google.firebase.dataconnect.querymgr.RegisteredDataDeserializer +import com.google.firebase.dataconnect.util.NullableReference +import com.google.firebase.dataconnect.util.SuspendingLazy +import com.google.firebase.util.nextAlphanumericString +import com.google.protobuf.Struct +import java.util.concurrent.Executor +import kotlin.random.Random +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal interface FirebaseDataConnectInternal : FirebaseDataConnect { + val logger: Logger + + val coroutineScope: CoroutineScope + val blockingExecutor: Executor + val blockingDispatcher: CoroutineDispatcher + val nonBlockingExecutor: Executor + val nonBlockingDispatcher: CoroutineDispatcher + + val lazyGrpcClient: SuspendingLazy + val lazyQueryManager: SuspendingLazy +} + +internal class FirebaseDataConnectImpl( + private val context: Context, + override val app: FirebaseApp, + private val projectId: String, + override val config: ConnectorConfig, + override val blockingExecutor: Executor, + override val nonBlockingExecutor: Executor, + private val deferredAuthProvider: com.google.firebase.inject.Deferred, + private val deferredAppCheckProvider: + com.google.firebase.inject.Deferred, + private val creator: FirebaseDataConnectFactory, + override val settings: DataConnectSettings, +) : FirebaseDataConnectInternal { + + override val logger = + Logger("FirebaseDataConnectImpl").apply { + debug { + "New instance created with " + + "app=${app.name}, projectId=$projectId, " + + "config=$config, settings=$settings" + } + } + val instanceId: String + get() = logger.nameWithId + + override val blockingDispatcher = blockingExecutor.asCoroutineDispatcher() + override val nonBlockingDispatcher = nonBlockingExecutor.asCoroutineDispatcher() + + override val coroutineScope = + CoroutineScope( + SupervisorJob() + + nonBlockingDispatcher + + CoroutineName(instanceId) + + CoroutineExceptionHandler { _, throwable -> + logger.warn(throwable) { "uncaught exception from a coroutine" } + } + ) + + // Protects `closed`, `grpcClient`, `emulatorSettings`, and `queryManager`. + private val mutex = Mutex() + + // All accesses to this variable _must_ have locked `mutex`. + private var emulatorSettings: EmulatedServiceSettings? = null + + // All accesses to this variable _must_ have locked `mutex`. + private var closed = false + + private val lazyDataConnectAuth = + SuspendingLazy(mutex) { + if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") + DataConnectAuth( + deferredAuthProvider = deferredAuthProvider, + parentCoroutineScope = coroutineScope, + blockingDispatcher = blockingDispatcher, + logger = Logger("DataConnectAuth").apply { debug { "created by $instanceId" } }, + ) + .apply { initialize() } + } + + private val lazyDataConnectAppCheck = + SuspendingLazy(mutex) { + if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") + DataConnectAppCheck( + deferredAppCheckTokenProvider = deferredAppCheckProvider, + parentCoroutineScope = coroutineScope, + blockingDispatcher = blockingDispatcher, + logger = Logger("DataConnectAppCheck").apply { debug { "created by $instanceId" } }, + ) + .apply { initialize() } + } + + private val lazyGrpcRPCs = + SuspendingLazy(mutex) { + if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") + + data class DataConnectBackendInfo( + val host: String, + val sslEnabled: Boolean, + val isEmulator: Boolean + ) + val backendInfoFromSettings = + DataConnectBackendInfo( + host = settings.host, + sslEnabled = settings.sslEnabled, + isEmulator = false + ) + val backendInfoFromEmulatorSettings = + emulatorSettings?.run { + DataConnectBackendInfo(host = "$host:$port", sslEnabled = false, isEmulator = true) + } + val backendInfo = + if (backendInfoFromEmulatorSettings == null) { + backendInfoFromSettings + } else { + if (!settings.isDefaultHost()) { + logger.warn( + "Host has been set in DataConnectSettings and useEmulator, " + + "emulator host will be used." + ) + } + backendInfoFromEmulatorSettings + } + + logger.debug { "connecting to Data Connect backend: $backendInfo" } + val grpcMetadata = + DataConnectGrpcMetadata.forSystemVersions( + firebaseApp = app, + dataConnectAuth = lazyDataConnectAuth.getLocked(), + dataConnectAppCheck = lazyDataConnectAppCheck.getLocked(), + connectorLocation = config.location, + parentLogger = logger, + ) + val dataConnectGrpcRPCs = + DataConnectGrpcRPCs( + context = context, + host = backendInfo.host, + sslEnabled = backendInfo.sslEnabled, + blockingCoroutineDispatcher = blockingDispatcher, + grpcMetadata = grpcMetadata, + parentLogger = logger, + ) + + if (backendInfo.isEmulator) { + logEmulatorVersion(dataConnectGrpcRPCs) + streamEmulatorErrors(dataConnectGrpcRPCs) + } + + dataConnectGrpcRPCs + } + + override val lazyGrpcClient = + SuspendingLazy(mutex) { + DataConnectGrpcClient( + projectId = projectId, + connector = config, + grpcRPCs = lazyGrpcRPCs.getLocked(), + dataConnectAuth = lazyDataConnectAuth.getLocked(), + dataConnectAppCheck = lazyDataConnectAppCheck.getLocked(), + logger = Logger("DataConnectGrpcClient").apply { debug { "created by $instanceId" } }, + ) + } + + override val lazyQueryManager = + SuspendingLazy(mutex) { + if (closed) throw IllegalStateException("FirebaseDataConnect instance has been closed") + val grpcClient = lazyGrpcClient.getLocked() + + val registeredDataDeserializerFactory = + object : LiveQuery.RegisteredDataDeserializerFactory { + override fun newInstance( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + parentLogger: Logger + ) = + RegisteredDataDeserializer( + dataDeserializer = dataDeserializer, + dataSerializersModule = dataSerializersModule, + blockingCoroutineDispatcher = blockingDispatcher, + parentLogger = parentLogger, + ) + } + val liveQueryFactory = + object : LiveQueries.LiveQueryFactory { + override fun newLiveQuery( + key: LiveQuery.Key, + operationName: String, + variables: Struct, + parentLogger: Logger + ) = + LiveQuery( + key = key, + operationName = operationName, + variables = variables, + parentCoroutineScope = coroutineScope, + nonBlockingCoroutineDispatcher = nonBlockingDispatcher, + grpcClient = grpcClient, + registeredDataDeserializerFactory = registeredDataDeserializerFactory, + parentLogger = parentLogger, + ) + } + val liveQueries = LiveQueries(liveQueryFactory, blockingDispatcher, parentLogger = logger) + QueryManager(liveQueries) + } + + override fun useEmulator(host: String, port: Int): Unit = runBlocking { + mutex.withLock { + if (lazyGrpcClient.initializedValueOrNull != null) { + throw IllegalStateException( + "Cannot call useEmulator() after instance has already been initialized." + ) + } + emulatorSettings = EmulatedServiceSettings(host = host, port = port) + } + } + + private fun logEmulatorVersion(dataConnectGrpcRPCs: DataConnectGrpcRPCs) { + val requestId = "gei" + Random.nextAlphanumericString(length = 6) + logger.debug { "[rid=$requestId] Getting Data Connect Emulator information" } + + val job = + coroutineScope.async { + val emulatorInfo = dataConnectGrpcRPCs.getEmulatorInfo(requestId) + logger.debug { "[rid=$requestId] Data Connect Emulator version: ${emulatorInfo.version}" } + + logger.debug { + "[rid=$requestId] Data Connect Emulator services" + + " (count=${emulatorInfo.servicesCount}):" + } + emulatorInfo.servicesList.forEachIndexed { index, serviceInfo -> + logger.debug { + "[rid=$requestId] service #${index+1}:" + + " serviceId=${serviceInfo.serviceId}" + + " connectionString=${serviceInfo.connectionString}" + } + } + } + + job.invokeOnCompletion { exception -> + if (exception !== null) { + logger.debug { + "[rid=$requestId] Getting Data Connect Emulator information FAILED: $exception" + } + } + } + } + + private fun streamEmulatorErrors(dataConnectGrpcRPCs: DataConnectGrpcRPCs) { + val requestId = "see" + Random.nextAlphanumericString(length = 6) + logger.debug { "[rid=$requestId] Streaming Data Connect Emulator errors" } + + val job = + coroutineScope.async { + // Do not log anything for each entry collected, as DataConnectGrpcRPCs already logs each + // received message and there is nothing for this method to add to it. + dataConnectGrpcRPCs.streamEmulatorIssues(requestId, config.serviceId).collect() + } + job.invokeOnCompletion { exception -> + if (!(exception === null || exception is CancellationException)) { + logger.debug { + "[rid=$requestId] Streaming Data Connect Emulator errors FAILED: $exception" + } + } + } + } + + override fun query( + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + optionsBuilder: (QueryRefOptionsBuilder.() -> Unit)?, + ): QueryRefImpl { + val options = + object : QueryRefOptionsBuilder { + override var callerSdkType: FirebaseDataConnect.CallerSdkType? = null + override var variablesSerializersModule: SerializersModule? = null + override var dataSerializersModule: SerializersModule? = null + } + optionsBuilder?.let { it(options) } + + return QueryRefImpl( + dataConnect = this, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = options.callerSdkType ?: FirebaseDataConnect.CallerSdkType.Base, + variablesSerializersModule = options.variablesSerializersModule, + dataSerializersModule = options.dataSerializersModule, + ) + } + + override fun mutation( + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + optionsBuilder: (MutationRefOptionsBuilder.() -> Unit)?, + ): MutationRefImpl { + val options = + object : MutationRefOptionsBuilder { + override var callerSdkType: FirebaseDataConnect.CallerSdkType? = null + override var variablesSerializersModule: SerializersModule? = null + override var dataSerializersModule: SerializersModule? = null + } + optionsBuilder?.let { it(options) } + + return MutationRefImpl( + dataConnect = this, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = options.callerSdkType ?: FirebaseDataConnect.CallerSdkType.Base, + variablesSerializersModule = options.variablesSerializersModule, + dataSerializersModule = options.dataSerializersModule, + ) + } + + private val closeJob = MutableStateFlow(NullableReference>(null)) + + override fun close() { + logger.debug { "close() called" } + @Suppress("DeferredResultUnused") runBlocking { nonBlockingClose() } + } + + override suspend fun suspendingClose() { + logger.debug { "suspendingClose() called" } + nonBlockingClose().await() + } + + private suspend fun nonBlockingClose(): Deferred { + coroutineScope.cancel() + + // Remove the reference to this `FirebaseDataConnect` instance from the + // `FirebaseDataConnectFactory` that created it, so that the next time that `getInstance()` is + // called with the same arguments that a new instance of `FirebaseDataConnect` will be created. + creator.remove(this) + + mutex.withLock { closed = true } + + // Close Auth and AppCheck synchronously to avoid race conditions with auth callbacks. + // Since close() is re-entrant, this is safe even if they have already been closed. + lazyDataConnectAuth.initializedValueOrNull?.close() + lazyDataConnectAppCheck.initializedValueOrNull?.close() + + // Start the job to asynchronously close the gRPC client. + while (true) { + val oldCloseJob = closeJob.value + + oldCloseJob.ref?.let { + if (!it.isCancelled) { + return it + } + } + + @OptIn(DelicateCoroutinesApi::class) + val newCloseJob = + GlobalScope.async(start = CoroutineStart.LAZY) { + lazyGrpcRPCs.initializedValueOrNull?.close() + } + + newCloseJob.invokeOnCompletion { exception -> + if (exception === null) { + logger.debug { "close() completed successfully" } + } else { + logger.warn(exception) { "close() failed" } + } + } + + if (closeJob.compareAndSet(oldCloseJob, NullableReference(newCloseJob))) { + newCloseJob.start() + return newCloseJob + } + } + } + + // The generated SDK relies on equals() and hashCode() using object identity. + // Although you get this for free by just calling the methods of the superclass, be explicit + // to ensure that nobody changes these implementations in the future. + override fun equals(other: Any?): Boolean = other === this + override fun hashCode(): Int = System.identityHashCode(this) + + override fun toString(): String = + "FirebaseDataConnect(app=${app.name}, projectId=$projectId, config=$config, settings=$settings)" + + private data class EmulatedServiceSettings(val host: String, val port: Int) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectRegistrar.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectRegistrar.kt new file mode 100644 index 00000000000..1b18e8d0a48 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectRegistrar.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.content.Context +import androidx.annotation.Keep +import androidx.annotation.RestrictTo +import com.google.firebase.FirebaseApp +import com.google.firebase.annotations.concurrent.Blocking +import com.google.firebase.annotations.concurrent.Lightweight +import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.components.Component +import com.google.firebase.components.ComponentRegistrar +import com.google.firebase.components.Dependency +import com.google.firebase.components.Qualified +import com.google.firebase.dataconnect.* +import com.google.firebase.platforminfo.LibraryVersionComponent +import java.util.concurrent.Executor + +/** + * [ComponentRegistrar] for setting up [FirebaseDataConnect]. + * + * @hide + */ +@Keep +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +internal class FirebaseDataConnectRegistrar : ComponentRegistrar { + + @Keep + override fun getComponents() = + listOf( + Component.builder(FirebaseDataConnectFactory::class.java) + .name(LIBRARY_NAME) + .add(Dependency.required(firebaseApp)) + .add(Dependency.required(context)) + .add(Dependency.required(blockingExecutor)) + .add(Dependency.required(nonBlockingExecutor)) + .add(Dependency.deferred(internalAuthProvider)) + .add(Dependency.deferred(interopAppCheckTokenProvider)) + .factory { container -> + FirebaseDataConnectFactory( + context = container.get(context), + firebaseApp = container.get(firebaseApp), + blockingExecutor = container.get(blockingExecutor), + nonBlockingExecutor = container.get(nonBlockingExecutor), + deferredAuthProvider = container.getDeferred(internalAuthProvider), + deferredAppCheckProvider = container.getDeferred(interopAppCheckTokenProvider), + ) + } + .build(), + LibraryVersionComponent.create(LIBRARY_NAME, BuildConfig.VERSION_NAME) + ) + + companion object { + private const val LIBRARY_NAME = "fire-data-connect" + + private val firebaseApp = Qualified.unqualified(FirebaseApp::class.java) + private val context = Qualified.unqualified(Context::class.java) + private val blockingExecutor = Qualified.qualified(Blocking::class.java, Executor::class.java) + private val nonBlockingExecutor = + Qualified.qualified(Lightweight::class.java, Executor::class.java) + private val internalAuthProvider = Qualified.unqualified(InternalAuthProvider::class.java) + private val interopAppCheckTokenProvider = + Qualified.unqualified(InteropAppCheckTokenProvider::class.java) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Globals.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Globals.kt new file mode 100644 index 00000000000..0bbaa2b6038 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Globals.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.FirebaseDataConnect +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +/** + * Holder for "global" functions in this package. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates + * XXXKt Java classes whose visibility cannot be controlled. Using an "internal" object, instead, to + * gather together the top-level functions avoids this public API pollution. + */ +internal object Globals { + @Suppress("SpellCheckingInspection") + private const val PLACEHOLDER_APP_CHECK_TOKEN = "eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==" + + /** + * Returns a new string that is equal to this string but only includes a chunk from the beginning + * and the end. + * + * This method assumes that the contents of this string are an access token. The returned string + * will have enough information to reason about the access token in logs without giving its value + * away. + */ + fun String.toScrubbedAccessToken(): String = + if (this == PLACEHOLDER_APP_CHECK_TOKEN) { + "$this (the \"placeholder\" AppCheck token)" + } else if (length < 30) { + "" + } else { + buildString { + append(this@toScrubbedAccessToken, 0, 6) + append("") + append( + this@toScrubbedAccessToken, + this@toScrubbedAccessToken.length - 6, + this@toScrubbedAccessToken.length + ) + } + } + + fun MutationRefImpl.copy( + dataConnect: FirebaseDataConnectInternal = this.dataConnect, + operationName: String = this.operationName, + variables: Variables = this.variables, + dataDeserializer: DeserializationStrategy = this.dataDeserializer, + variablesSerializer: SerializationStrategy = this.variablesSerializer, + callerSdkType: FirebaseDataConnect.CallerSdkType = this.callerSdkType, + variablesSerializersModule: SerializersModule? = this.variablesSerializersModule, + dataSerializersModule: SerializersModule? = this.dataSerializersModule, + ) = + MutationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) + + fun MutationRefImpl.withVariablesSerializer( + variables: NewVariables, + variablesSerializer: SerializationStrategy, + variablesSerializersModule: SerializersModule? = this.variablesSerializersModule, + ): MutationRefImpl = + MutationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) + + fun MutationRefImpl<*, Variables>.withDataDeserializer( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule? = this.dataSerializersModule, + ): MutationRefImpl = + MutationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) + + fun QueryRefImpl.copy( + dataConnect: FirebaseDataConnectInternal = this.dataConnect, + operationName: String = this.operationName, + variables: Variables = this.variables, + dataDeserializer: DeserializationStrategy = this.dataDeserializer, + variablesSerializer: SerializationStrategy = this.variablesSerializer, + callerSdkType: FirebaseDataConnect.CallerSdkType = this.callerSdkType, + variablesSerializersModule: SerializersModule? = this.variablesSerializersModule, + dataSerializersModule: SerializersModule? = this.dataSerializersModule, + ) = + QueryRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Logger.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Logger.kt new file mode 100644 index 00000000000..d68cb8492c1 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Logger.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import android.util.Log +import com.google.firebase.dataconnect.BuildConfig +import com.google.firebase.dataconnect.LogLevel +import com.google.firebase.dataconnect.core.LoggerGlobals.LOG_TAG +import com.google.firebase.util.nextAlphanumericString +import kotlin.random.Random + +internal interface Logger { + val name: String + val id: String + val nameWithId: String + + fun log(exception: Throwable?, level: LogLevel, message: String) +} + +private class LoggerImpl(override val name: String) : Logger { + + override val id: String by + lazy(LazyThreadSafetyMode.PUBLICATION) { "lgr" + Random.nextAlphanumericString(length = 10) } + + override val nameWithId: String by lazy(LazyThreadSafetyMode.PUBLICATION) { "$name[id=$id]" } + + override fun log(exception: Throwable?, level: LogLevel, message: String) { + val fullMessage = "[${BuildConfig.VERSION_NAME}] $nameWithId $message" + when (level) { + LogLevel.DEBUG -> Log.d(LOG_TAG, fullMessage, exception) + LogLevel.WARN -> Log.w(LOG_TAG, fullMessage, exception) + LogLevel.NONE -> {} + } + } +} + +/** + * Holder for "global" functions related to [Logger]. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates a + * LoggerKt Java class with public visibility, which pollutes the public API. Using an "internal" + * object, instead, to gather together the top-level functions avoids this public API pollution. + */ +internal object LoggerGlobals { + const val LOG_TAG = "FirebaseDataConnect" + + @Volatile var logLevel: LogLevel = LogLevel.WARN + + inline fun Logger.debug(message: () -> Any?) { + if (logLevel <= LogLevel.DEBUG) debug("${message()}") + } + + fun Logger.debug(message: String) { + if (logLevel <= LogLevel.DEBUG) log(null, LogLevel.DEBUG, message) + } + + inline fun Logger.warn(message: () -> Any?) { + if (logLevel <= LogLevel.WARN) warn("${message()}") + } + + inline fun Logger.warn(exception: Throwable?, message: () -> Any?) { + if (logLevel <= LogLevel.WARN) warn(exception, "${message()}") + } + + fun Logger.warn(message: String) { + warn(null, message) + } + + fun Logger.warn(exception: Throwable?, message: String) { + if (logLevel <= LogLevel.WARN) log(exception, LogLevel.WARN, message) + } + + fun Logger(name: String): Logger = LoggerImpl(name) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt new file mode 100644 index 00000000000..59330ab4d3b --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.deserialize +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto +import com.google.firebase.util.nextAlphanumericString +import java.util.Objects +import kotlin.random.Random +import kotlinx.coroutines.withContext +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class MutationRefImpl( + dataConnect: FirebaseDataConnectInternal, + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + callerSdkType: FirebaseDataConnect.CallerSdkType, + variablesSerializersModule: SerializersModule?, + dataSerializersModule: SerializersModule?, +) : + MutationRef, + OperationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) { + + internal val logger = Logger("MutationRefImpl[$operationName]") + + override suspend fun execute(): MutationResultImpl { + val requestId = "mut" + Random.nextAlphanumericString(length = 10) + return dataConnect.lazyGrpcClient + .get() + .executeMutation( + requestId = requestId, + operationName = operationName, + variables = + withContext(dataConnect.blockingDispatcher) { + if (variablesSerializer === DataConnectUntypedVariables.Serializer) { + (variables as DataConnectUntypedVariables).variables.toStructProto() + } else { + encodeToStruct(variables, variablesSerializer, variablesSerializersModule) + } + }, + callerSdkType, + ) + .runCatching { + withContext(dataConnect.blockingDispatcher) { + deserialize(dataDeserializer, dataSerializersModule) + } + } + .onFailure { + logger.warn(it) { "executeMutation() [rid=$requestId] decoding response data failed: $it" } + } + .getOrThrow() + .let { MutationResultImpl(it) } + } + + override fun hashCode(): Int = Objects.hash("MutationRefImpl", super.hashCode()) + + override fun equals(other: Any?): Boolean = other is MutationRefImpl<*, *> && super.equals(other) + + override fun toString(): String = + "MutationRefImpl(" + + "dataConnect=$dataConnect, " + + "operationName=$operationName, " + + "variables=$variables, " + + "dataDeserializer=$dataDeserializer, " + + "variablesSerializer=$variablesSerializer, " + + "callerSdkType=$callerSdkType, " + + "variablesSerializersModule=$variablesSerializersModule, " + + "dataSerializersModule=$dataSerializersModule" + + ")" + + inner class MutationResultImpl(data: Data) : + MutationResult, OperationRefImpl.OperationResultImpl(data) { + + override val ref = this@MutationRefImpl + + override fun equals(other: Any?) = + other is MutationRefImpl<*, *>.MutationResultImpl && super.equals(other) + + override fun hashCode() = Objects.hash(MutationResultImpl::class, data, ref) + + override fun toString() = "MutationResultImpl(data=$data, ref=$ref)" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/OperationRefImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/OperationRefImpl.kt new file mode 100644 index 00000000000..592ee38f760 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/OperationRefImpl.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* +import java.util.Objects +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal abstract class OperationRefImpl( + override val dataConnect: FirebaseDataConnectInternal, + override val operationName: String, + override val variables: Variables, + override val dataDeserializer: DeserializationStrategy, + override val variablesSerializer: SerializationStrategy, + override val callerSdkType: FirebaseDataConnect.CallerSdkType, + override val variablesSerializersModule: SerializersModule?, + override val dataSerializersModule: SerializersModule?, +) : OperationRef { + abstract override suspend fun execute(): OperationResultImpl + + override fun hashCode() = + Objects.hash( + dataConnect, + operationName, + variables, + dataDeserializer, + variablesSerializer, + callerSdkType, + variablesSerializersModule, + dataSerializersModule + ) + + override fun equals(other: Any?) = + other is OperationRefImpl<*, *> && + other.dataConnect == dataConnect && + other.operationName == operationName && + other.variables == variables && + other.dataDeserializer == dataDeserializer && + other.variablesSerializer == variablesSerializer && + other.callerSdkType == callerSdkType && + other.variablesSerializersModule == variablesSerializersModule && + other.dataSerializersModule == dataSerializersModule + + override fun toString() = + "OperationRefImpl(" + + "dataConnect=$dataConnect, " + + "operationName=$operationName, " + + "variables=$variables, " + + "dataDeserializer=$dataDeserializer, " + + "variablesSerializer=$variablesSerializer" + + "callerSdkType=$callerSdkType, " + + "variablesSerializersModule=$variablesSerializersModule, " + + "dataSerializersModule=$dataSerializersModule" + + ")" + + abstract inner class OperationResultImpl(override val data: Data) : + OperationResult { + + override val ref = this@OperationRefImpl + + override fun equals(other: Any?) = + other is OperationRefImpl<*, *>.OperationResultImpl && other.data == data && other.ref == ref + + override fun hashCode() = Objects.hash(OperationResultImpl::class, data, ref) + + override fun toString() = "OperationResultImpl(data=$data, ref=$ref)" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt new file mode 100644 index 00000000000..fc35592bba7 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* +import java.util.Objects +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class QueryRefImpl( + dataConnect: FirebaseDataConnectInternal, + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + callerSdkType: FirebaseDataConnect.CallerSdkType, + variablesSerializersModule: SerializersModule?, + dataSerializersModule: SerializersModule?, +) : + QueryRef, + OperationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) { + override suspend fun execute(): QueryResultImpl = + dataConnect.lazyQueryManager.get().execute(this).let { QueryResultImpl(it.ref.getOrThrow()) } + + override fun subscribe(): QuerySubscription = QuerySubscriptionImpl(this) + + override fun hashCode(): Int = Objects.hash("QueryRefImpl", super.hashCode()) + + override fun equals(other: Any?): Boolean = other is QueryRefImpl<*, *> && super.equals(other) + + override fun toString(): String = + "QueryRefImpl(" + + "dataConnect=$dataConnect, " + + "operationName=$operationName, " + + "variables=$variables, " + + "dataDeserializer=$dataDeserializer, " + + "variablesSerializer=$variablesSerializer, " + + "callerSdkType=$callerSdkType, " + + "variablesSerializersModule=$variablesSerializersModule, " + + "dataSerializersModule=$dataSerializersModule" + + ")" + + inner class QueryResultImpl(data: Data) : + QueryResult, OperationRefImpl.OperationResultImpl(data) { + + override val ref = this@QueryRefImpl + + override fun equals(other: Any?) = + other is QueryRefImpl<*, *>.QueryResultImpl && super.equals(other) + + override fun hashCode() = Objects.hash(QueryResultImpl::class, data, ref) + + override fun toString() = "QueryResultImpl(data=$data, ref=$ref)" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt new file mode 100644 index 00000000000..58c7fd63e39 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.core.Globals.copy +import com.google.firebase.dataconnect.util.NullableReference +import com.google.firebase.dataconnect.util.SequencedReference +import java.util.Objects +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +internal class QuerySubscriptionImpl(query: QueryRefImpl) : + QuerySubscriptionInternal { + private val _query = MutableStateFlow(query) + override val query: QueryRefImpl by _query::value + + private val _lastResult = MutableStateFlow(NullableReference()) + override val lastResult: QuerySubscriptionResult? + get() = _lastResult.value.ref + + // Each collection of this flow triggers an implicit `reload()`. + override val flow: Flow> = channelFlow { + lastResult?.also { send(it) } + + var collectJob: Job? = null + _query.collect { query -> + // We only need to execute the query upon initially collecting the flow. Subsequent changes to + // the variables automatically get a call to reload() by update(). + val shouldExecuteQuery = + collectJob.let { + if (it === null) { + true + } else { + it.cancelAndJoin() + false + } + } + + collectJob = launch { + val queryManager = query.dataConnect.lazyQueryManager.get() + queryManager.subscribe(query, executeQuery = shouldExecuteQuery) { sequencedResult -> + val querySubscriptionResult = QuerySubscriptionResultImpl(query, sequencedResult) + send(querySubscriptionResult) + updateLastResult(querySubscriptionResult) + } + } + } + } + + override suspend fun reload() { + val query = query // save query to a local variable in case it changes. + val sequencedResult = query.dataConnect.lazyQueryManager.get().execute(query) + updateLastResult(QuerySubscriptionResultImpl(query, sequencedResult)) + sequencedResult.ref.getOrThrow() + } + + override suspend fun update(variables: Variables) { + _query.value = _query.value.copy(variables = variables) + reload() + } + + private fun updateLastResult(prospectiveLastResult: QuerySubscriptionResultImpl) { + // Update the last result in a compare-and-swap loop so that there is no possibility of + // clobbering a newer result with an older result, compared using their sequence numbers. + // TODO: Fix this so that results from an old query do not clobber results from a new query, + // as set by a call to update() + while (true) { + val currentLastResult = _lastResult.value + if (currentLastResult.ref != null) { + val currentSequenceNumber = currentLastResult.ref.sequencedResult.sequenceNumber + val prospectiveSequenceNumber = prospectiveLastResult.sequencedResult.sequenceNumber + if (currentSequenceNumber >= prospectiveSequenceNumber) { + return + } + } + + if (_lastResult.compareAndSet(currentLastResult, NullableReference(prospectiveLastResult))) { + return + } + } + } + + override fun equals(other: Any?): Boolean = other === this + + override fun hashCode(): Int = System.identityHashCode(this) + + override fun toString(): String = "QuerySubscription(query=$query)" + + private inner class QuerySubscriptionResultImpl( + override val query: QueryRefImpl, + val sequencedResult: SequencedReference> + ) : QuerySubscriptionResult { + override val result = sequencedResult.ref.map { query.QueryResultImpl(it) } + + override fun equals(other: Any?) = + other is QuerySubscriptionImpl<*, *>.QuerySubscriptionResultImpl && + other.query == query && + other.result == result + + override fun hashCode() = Objects.hash(QuerySubscriptionResultImpl::class, query, result) + + override fun toString() = "QuerySubscriptionResultImpl(query=$query, result=$result)" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionInternal.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionInternal.kt new file mode 100644 index 00000000000..d10b4b9758f --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionInternal.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.* + +internal interface QuerySubscriptionInternal : QuerySubscription { + val lastResult: QuerySubscriptionResult? + + suspend fun reload() + + suspend fun update(variables: Variables) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedConnector.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedConnector.kt new file mode 100644 index 00000000000..fa155144e5a --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedConnector.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.generated + +import com.google.firebase.dataconnect.* + +/** + * The interface to be implemented by the over-arching "connector" classes that are generated by the + * Firebase Tools code generation. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [GeneratedConnector] are thread-safe and may be safely called + * and/or accessed concurrently from multiple threads and/or coroutines. + * + * ### Stable for Inheritance (after graduating to "Generally Available") + * + * The [GeneratedConnector] interface _is_ stable for inheritance in third-party libraries, as new + * methods will not be added to this interface and contracts of the existing methods will not be + * changed. Note, however, that this interface is still subject to changes, up to and including + * outright deletion, until the Firebase Data Connect product graduates from "alpha" and/or "beta" + * to "Generally Available" status. + */ +public interface GeneratedConnector { + + /** The [FirebaseDataConnect] instance used by this object. */ + public val dataConnect: FirebaseDataConnect + + /** + * Compares this object with another object for equality, using the `===` operator. + * + * The implementation of this method simply uses referential equality. That is, two instances of + * [GeneratedConnector] compare equal using this method if, and only if, they refer to the same + * object, as determined by the `===` operator. + * + * @param other The object to compare to this for equality. + * @return `other === this` + */ + override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * @return the hash code for this object. + */ + override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object. + */ + override fun toString(): String +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedMutation.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedMutation.kt new file mode 100644 index 00000000000..3c4974eff68 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedMutation.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.generated + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.MutationRef + +/** + * The specialization of [GeneratedOperation] for mutations. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [GeneratedMutation] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Stable for Inheritance (after graduating to "Generally Available") + * + * The [GeneratedMutation] interface _is_ stable for inheritance in third-party libraries, as new + * methods will not be added to this interface and contracts of the existing methods will not be + * changed. Note, however, that this interface is still subject to changes, up to and including + * outright deletion, until the Firebase Data Connect product graduates from "alpha" and/or "beta" + * to "Generally Available" status. + */ +public interface GeneratedMutation : + GeneratedOperation { + override fun ref(variables: Variables): MutationRef = + connector.dataConnect.mutation( + operationName, + variables, + dataDeserializer, + variablesSerializer, + ) { + callerSdkType = FirebaseDataConnect.CallerSdkType.Generated + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedOperation.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedOperation.kt new file mode 100644 index 00000000000..9533c3b2f13 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedOperation.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.generated + +import com.google.firebase.dataconnect.OperationRef +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy + +/** + * The parent of [GeneratedQuery] and [GeneratedMutation], which are to be implemented by per-query + * and per-mutation classes, respectively, generated by the Firebase Tools code generation. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [GeneratedOperation] are thread-safe and may be safely called + * and/or accessed concurrently from multiple threads and/or coroutines. + * + * ### Stable for Inheritance (after graduating to "Generally Available") + * + * The [GeneratedOperation] interface _is_ stable for inheritance in third-party libraries, as new + * methods will not be added to this interface and contracts of the existing methods will not be + * changed. Note, however, that this interface is still subject to changes, up to and including + * outright deletion, until the Firebase Data Connect product graduates from "alpha" and/or "beta" + * to "Generally Available" status. + */ +public interface GeneratedOperation { + + /** The [GeneratedConnector] with which this object is associated. */ + public val connector: Connector + + /** + * The name of the operation, as defined in GraphQL. + * @see OperationRef.operationName + */ + public val operationName: String + + /** + * The deserializer to use to deserialize the response data for this operation. + * @see OperationRef.dataDeserializer + */ + public val dataDeserializer: DeserializationStrategy + + /** + * The serializer to use to serialize the variables for this operation. + * @see OperationRef.variablesSerializer + */ + public val variablesSerializer: SerializationStrategy + + /** + * Returns a [OperationRef] that can be used to execute this operation with the given variables. + */ + public fun ref(variables: Variables): OperationRef = + connector.dataConnect.mutation(operationName, variables, dataDeserializer, variablesSerializer) + + /** + * Compares this object with another object for equality, using the `===` operator. + * + * The implementation of this method simply uses referential equality. That is, two instances of + * [GeneratedOperation] compare equal using this method if, and only if, they refer to the same + * object, as determined by the `===` operator. + * + * @param other The object to compare to this for equality. + * @return `other === this` + */ + // TODO: Uncomment equals() once the codegen changes in cl/634029357 are released in the latest + // firestore-tools for a month or so, as adding this method is a breaking change as it forces the + // generated classes to explicitly override this method. + // override fun equals(other: Any?): Boolean + + /** + * Calculates and returns the hash code for this object. + * + * @return the hash code for this object. + */ + // TODO: Uncomment hashCode() once the codegen changes in cl/634029357 are released in the latest + // firestore-tools for a month or so, as adding this method is a breaking change as it forces the + // generated classes to explicitly override this method. + // override fun hashCode(): Int + + /** + * Returns a string representation of this object, useful for debugging. + * + * The string representation is _not_ guaranteed to be stable and may change without notice at any + * time. Therefore, the only recommended usage of the returned string is debugging and/or logging. + * Namely, parsing the returned string or storing the returned string in non-volatile storage + * should generally be avoided in order to be robust in case that the string representation + * changes. + * + * @return a string representation of this object, which includes the class name and the values of + * all public properties. + */ + override fun toString(): String +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedQuery.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedQuery.kt new file mode 100644 index 00000000000..eb5f8a5be5b --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedQuery.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.generated + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.QueryRef + +/** + * The specialization of [GeneratedOperation] for queries. + * + * ### Safe for Concurrent Use + * + * All methods and properties of [GeneratedQuery] are thread-safe and may be safely called and/or + * accessed concurrently from multiple threads and/or coroutines. + * + * ### Stable for Inheritance (after graduating to "Generally Available") + * + * The [GeneratedQuery] interface _is_ stable for inheritance in third-party libraries, as new + * methods will not be added to this interface and contracts of the existing methods will not be + * changed. Note, however, that this interface is still subject to changes, up to and including + * outright deletion, until the Firebase Data Connect product graduates from "alpha" and/or "beta" + * to "Generally Available" status. + */ +public interface GeneratedQuery : + GeneratedOperation { + override fun ref(variables: Variables): QueryRef = + connector.dataConnect.query( + operationName, + variables, + dataDeserializer, + variablesSerializer, + ) { + callerSdkType = FirebaseDataConnect.CallerSdkType.Generated + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQueries.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQueries.kt new file mode 100644 index 00000000000..9fbb587a50b --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQueries.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.querymgr + +import com.google.firebase.dataconnect.* +import com.google.firebase.dataconnect.core.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.util.AlphanumericStringUtil.toAlphaNumericString +import com.google.firebase.dataconnect.util.ProtoUtil.calculateSha512 +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto +import com.google.firebase.dataconnect.util.ReferenceCounted +import com.google.protobuf.Struct +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class LiveQueries( + private val liveQueryFactory: LiveQueryFactory, + private val blockingDispatcher: CoroutineDispatcher, + parentLogger: Logger, +) { + private val logger = + Logger("LiveQueries").apply { debug { "created by ${parentLogger.nameWithId}" } } + val instanceId: String + get() = logger.nameWithId + + private val mutex = Mutex() + + // NOTE: All accesses to `referenceCountedLiveQueryByKey` and the `refCount` field of each value + // MUST be done from a coroutine that has locked `mutex`; otherwise, such accesses (both reads and + // writes) are data races and yield undefined behavior. + private val referenceCountedLiveQueryByKey = + mutableMapOf>() + + suspend fun withLiveQuery(query: QueryRef, block: suspend (LiveQuery) -> T): T { + val liveQuery = mutex.withLock { acquireLiveQuery(query) } + + return try { + block(liveQuery) + } finally { + withContext(NonCancellable) { mutex.withLock { releaseLiveQuery(liveQuery) } } + } + } + + // NOTE: This function MUST be called from a coroutine that has locked `mutex`. + private suspend fun acquireLiveQuery(query: QueryRef): LiveQuery { + val variablesStruct = + withContext(blockingDispatcher) { + if (query.variablesSerializer === DataConnectUntypedVariables.Serializer) { + (query.variables as DataConnectUntypedVariables).variables.toStructProto() + } else { + encodeToStruct( + query.variables, + query.variablesSerializer, + query.variablesSerializersModule + ) + } + } + + val variablesHash = + withContext(blockingDispatcher) { variablesStruct.calculateSha512().toAlphaNumericString() } + + val key = LiveQuery.Key(operationName = query.operationName, variablesHash = variablesHash) + + val referenceCountedLiveQuery = + referenceCountedLiveQueryByKey.getOrPut(key) { + val liveQuery = + liveQueryFactory.newLiveQuery(key, query.operationName, variablesStruct, logger) + ReferenceCounted(liveQuery, refCount = 0) + } + + referenceCountedLiveQuery.refCount++ + + return referenceCountedLiveQuery.obj + } + + // NOTE: This function MUST be called from a coroutine that has locked `mutex`. + private fun releaseLiveQuery(liveQuery: LiveQuery) { + val referenceCountedLiveQuery = referenceCountedLiveQueryByKey[liveQuery.key] + + if (referenceCountedLiveQuery === null) { + error("unexpected null LiveQuery for key: ${liveQuery.key}") + } else if (referenceCountedLiveQuery.obj !== liveQuery) { + error("unexpected LiveQuery for key: ${liveQuery.key}: ${referenceCountedLiveQuery.obj}") + } + + referenceCountedLiveQuery.refCount-- + if (referenceCountedLiveQuery.refCount == 0) { + logger.debug { "refCount==0 for LiveQuery with key=${liveQuery.key}; removing the mapping" } + referenceCountedLiveQueryByKey.remove(liveQuery.key) + liveQuery.close() + } + } + + interface LiveQueryFactory { + fun newLiveQuery( + key: LiveQuery.Key, + operationName: String, + variables: Struct, + parentLogger: Logger + ): LiveQuery + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQuery.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQuery.kt new file mode 100644 index 00000000000..25d6344524d --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQuery.kt @@ -0,0 +1,261 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.querymgr + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.core.DataConnectGrpcClient +import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult +import com.google.firebase.dataconnect.core.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.util.NullableReference +import com.google.firebase.dataconnect.util.SequencedReference +import com.google.firebase.dataconnect.util.SequencedReference.Companion.map +import com.google.firebase.dataconnect.util.SequencedReference.Companion.nextSequenceNumber +import com.google.firebase.util.nextAlphanumericString +import com.google.protobuf.Struct +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.random.Random +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class LiveQuery( + val key: Key, + private val operationName: String, + private val variables: Struct, + parentCoroutineScope: CoroutineScope, + nonBlockingCoroutineDispatcher: CoroutineDispatcher, + private val grpcClient: DataConnectGrpcClient, + private val registeredDataDeserializerFactory: RegisteredDataDeserializerFactory, + parentLogger: Logger, +) : AutoCloseable { + private val logger = + Logger("LiveQuery").apply { + debug { + "created by ${parentLogger.nameWithId} with" + + " operationName=$operationName" + + " variables=$variables" + + " key=$key" + + " grpcClient=${grpcClient.instanceId}" + } + } + + private val coroutineScope = + CoroutineScope( + SupervisorJob(parentCoroutineScope.coroutineContext[Job]) + + nonBlockingCoroutineDispatcher + + CoroutineName("LiveQuery[${logger.nameWithId}]") + ) + + // The `dataDeserializers` list may be safely read concurrently from multiple threads, as it uses + // a `CopyOnWriteArrayList` that is completely thread-safe. Any mutating operations must be + // performed while the `dataDeserializersWriteMutex` mutex is locked, so that + // read-write-modify operations can be done atomically. + private val dataDeserializersWriteMutex = Mutex() + private val dataDeserializers = CopyOnWriteArrayList>() + private data class Update( + val requestId: String, + val sequencedResult: SequencedReference> + ) + // Also, `initialDataDeserializerUpdate` must only be accessed while + // `dataDeserializersWriteMutex` is held. + private val initialDataDeserializerUpdate = + MutableStateFlow>(NullableReference(null)) + + private val jobMutex = Mutex() + private var job: Job? = null + + suspend fun execute( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + callerSdkType: FirebaseDataConnect.CallerSdkType, + ): SequencedReference> { + // Register the data deserializer _before_ waiting for the current job to complete. This + // guarantees that the deserializer will be registered by the time the subsequent job (`newJob` + // below) runs. + val registeredDataDeserializer = + registerDataDeserializer(dataDeserializer, dataSerializersModule) + + // Wait for the current job to complete (if any), and ignore its result. Waiting avoids running + // multiple queries in parallel, which would not scale. + val originalJob = jobMutex.withLock { job }?.also { it.join() } + + // Now that the job that was in progress when this method started has completed, we can run our + // own query. But we're racing with other concurrent invocations of this method. The first one + // wins and launches the new job, then awaits its completion; the others simply await completion + // of the new job that was started by the winner. + val newJob = + jobMutex.withLock { + job.let { currentJob -> + if (currentJob !== null && currentJob !== originalJob) { + logger.debug { "using in-flight job to execute query" } + currentJob + } else { + logger.debug { "creating new job to execute query" } + coroutineScope.async { doExecute(callerSdkType) }.also { newJob -> job = newJob } + } + } + } + + newJob.join() + + return registeredDataDeserializer.getLatestUpdate()!! + } + + suspend fun subscribe( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + executeQuery: Boolean, + callerSdkType: FirebaseDataConnect.CallerSdkType, + callback: suspend (SequencedReference>) -> Unit, + ): Nothing { + val registeredDataDeserializer = + registerDataDeserializer(dataDeserializer, dataSerializersModule) + + // Immediately deliver the most recent update to the callback, so the collector has some data + // to work with while waiting for the network requests to complete. + val cachedUpdate = registeredDataDeserializer.getLatestSuccessfulUpdate() + val effectiveSinceSequenceNumber = + if (cachedUpdate === null) { + 0 + } else { + callback(cachedUpdate.map { Result.success(it) }) + cachedUpdate.sequenceNumber + } + + // Execute the query _after_ delivering the cached result, so that collectors deterministically + // get invoked with cached results first (if any), then updated results after the query + // executes. + if (executeQuery) { + coroutineScope.launch { + runCatching { execute(dataDeserializer, dataSerializersModule, callerSdkType) } + } + } + + registeredDataDeserializer.onSuccessfulUpdate( + sinceSequenceNumber = effectiveSinceSequenceNumber + ) { + callback(it) + } + } + + private suspend fun doExecute(callerSdkType: FirebaseDataConnect.CallerSdkType) { + val requestId = "qry" + Random.nextAlphanumericString(length = 10) + val sequenceNumber = nextSequenceNumber() + + val executeQueryResult = + grpcClient.runCatching { + logger.debug( + "Calling executeQuery() with requestId=$requestId callerSdkType=$callerSdkType" + ) + executeQuery( + requestId = requestId, + operationName = operationName, + variables = variables, + callerSdkType = callerSdkType, + ) + } + + // Normally, setting the value of `initialDataDeserializerUpdate` would be done in a compare- + // and-swap ("CAS") loop to avoid clobbering a newer update with an older one; however, since + // all writes _must_ be done by a coroutine with `dataDeserializersWriteMutex` locked, the CAS + // loop isn't necessary and its value can just be set directly. + dataDeserializersWriteMutex.withLock { + initialDataDeserializerUpdate.value.let { + it.ref.let { oldUpdate -> + if (oldUpdate === null || oldUpdate.sequencedResult.sequenceNumber < sequenceNumber) { + initialDataDeserializerUpdate.value = + NullableReference( + Update(requestId, SequencedReference(sequenceNumber, executeQueryResult)) + ) + } + } + } + } + + dataDeserializers.iterator().forEach { + it.update(requestId, SequencedReference(sequenceNumber, executeQueryResult)) + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun registerDataDeserializer( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + ): RegisteredDataDeserializer = + // First, check if the deserializer is already registered and, if it is, just return it. + // Otherwise, lock the "write" mutex and register it. We still have to check again if it is + // already registered because another thread could have concurrently registered it since we last + // checked above. + dataDeserializers + .firstOrNull { + it.dataDeserializer === dataDeserializer && + it.dataSerializersModule === dataSerializersModule + } + ?.let { it as RegisteredDataDeserializer } + ?: dataDeserializersWriteMutex.withLock { + dataDeserializers + .firstOrNull { + it.dataDeserializer === dataDeserializer && + it.dataSerializersModule === dataSerializersModule + } + ?.let { it as RegisteredDataDeserializer } + ?: run { + logger.debug { + "Registering data deserializer $dataDeserializer " + + "(dataSerializersModule=$dataSerializersModule)" + } + val registeredDataDeserializer = + registeredDataDeserializerFactory.newInstance( + dataDeserializer, + dataSerializersModule, + logger + ) + dataDeserializers.add(registeredDataDeserializer) + initialDataDeserializerUpdate.value.ref?.run { + registeredDataDeserializer.update(requestId, sequencedResult) + } + registeredDataDeserializer + } + } + + data class Key(val operationName: String, val variablesHash: String) + + override fun close() { + logger.debug("close() called") + coroutineScope.cancel() + } + + interface RegisteredDataDeserializerFactory { + fun newInstance( + dataDeserializer: DeserializationStrategy, + dataSerializersModule: SerializersModule?, + parentLogger: Logger + ): RegisteredDataDeserializer + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/QueryManager.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/QueryManager.kt new file mode 100644 index 00000000000..79e481d8819 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/QueryManager.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.querymgr + +import com.google.firebase.dataconnect.core.QueryRefImpl +import com.google.firebase.dataconnect.util.SequencedReference + +internal class QueryManager(private val liveQueries: LiveQueries) { + suspend fun execute( + query: QueryRefImpl, + ): SequencedReference> = + liveQueries.withLiveQuery(query) { + it.execute( + dataDeserializer = query.dataDeserializer, + dataSerializersModule = query.dataSerializersModule, + callerSdkType = query.callerSdkType, + ) + } + + suspend fun subscribe( + query: QueryRefImpl, + executeQuery: Boolean, + callback: suspend (SequencedReference>) -> Unit, + ): Nothing = + liveQueries.withLiveQuery(query) { liveQuery -> + liveQuery.subscribe( + dataDeserializer = query.dataDeserializer, + dataSerializersModule = query.dataSerializersModule, + executeQuery = executeQuery, + callerSdkType = query.callerSdkType, + callback = callback, + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt new file mode 100644 index 00000000000..3f94a7f95a0 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.querymgr + +import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.deserialize +import com.google.firebase.dataconnect.core.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.debug +import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.util.NullableReference +import com.google.firebase.dataconnect.util.SequencedReference +import com.google.firebase.dataconnect.util.SequencedReference.Companion.mapSuspending +import com.google.firebase.dataconnect.util.SuspendingLazy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.withContext +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class RegisteredDataDeserializer( + val dataDeserializer: DeserializationStrategy, + val dataSerializersModule: SerializersModule?, + private val blockingCoroutineDispatcher: CoroutineDispatcher, + parentLogger: Logger, +) { + private val logger = + Logger("RegisteredDataDeserializer").apply { + debug { + "created by ${parentLogger.nameWithId} with" + + " dataDeserializer=$dataDeserializer," + + " dataSerializersModule=$dataSerializersModule" + } + } + // A flow that emits a value every time that there is an update, either a successful or an + // unsuccessful update. There is no replay cache in this shared flow because there is no way to + // atomically emit a new event and ensure that it has a larger sequence number, and we don't want + // to "replay" an older result. Use `latestUpdate` instead of relying on the replay cache. + private val updates = + MutableSharedFlow>>>( + replay = 0, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + // The latest update (i.e. the update with the highest sequence number) that has ever been emitted + // to `updates`. The `ref` of the value will be null if, and only if, no updates have ever + // occurred. + private val latestUpdate = + MutableStateFlow>>>>( + NullableReference(null) + ) + + // The same as `latestUpdate`, except that it only store the latest _successful_ update. That is, + // if there was a successful update followed by a failed update then the value of this flow would + // be that successful update, whereas `latestUpdate` would store the failed one. + // + // This flow is updated by initializing the lazy value from `latestUpdate`; therefore, make sure + // to initialize the lazy value from `latestUpdate` before getting this flow's value. + private val latestSuccessfulUpdate = + MutableStateFlow>>(NullableReference(null)) + + fun update(requestId: String, sequencedResult: SequencedReference>) { + val newUpdate = + SequencedReference( + sequencedResult.sequenceNumber, + lazyDeserialize(requestId, sequencedResult) + ) + + // Use a compare-and-swap ("CAS") loop to ensure that an old update never clobbers a newer one. + while (true) { + val currentUpdate = latestUpdate.value + if ( + currentUpdate.ref !== null && + currentUpdate.ref.sequenceNumber > sequencedResult.sequenceNumber + ) { + break // don't clobber a newer update with an older one + } + if (latestUpdate.compareAndSet(currentUpdate, NullableReference(newUpdate))) { + break + } + } + + // Emit to the `updates` shared flow _after_ setting `latestUpdate` to avoid others missing + // the latest update. + val emitSucceeded = updates.tryEmit(newUpdate) + check(emitSucceeded) { "updates.tryEmit(newUpdate) should have returned true" } + } + + suspend fun getLatestUpdate(): SequencedReference>? = + latestUpdate.value.ref?.mapSuspending { it.get() } + + suspend fun getLatestSuccessfulUpdate(): SequencedReference? { + // Call getLatestUpdate() to populate `latestSuccessfulUpdate` with the most recent update. + getLatestUpdate() + return latestSuccessfulUpdate.value.ref + } + + suspend fun onSuccessfulUpdate( + sinceSequenceNumber: Long?, + callback: suspend (SequencedReference>) -> Unit + ): Nothing { + var lastSequenceNumber = sinceSequenceNumber ?: Long.MIN_VALUE + updates + .onSubscription { latestUpdate.value.ref?.let { emit(it) } } + .collect { update -> + if (update.sequenceNumber > lastSequenceNumber) { + lastSequenceNumber = update.sequenceNumber + callback(update.mapSuspending { it.get() }) + } + } + } + + private fun lazyDeserialize( + requestId: String, + sequencedResult: SequencedReference> + ): SuspendingLazy> = SuspendingLazy { + sequencedResult.ref + .mapCatching { + withContext(blockingCoroutineDispatcher) { + it.deserialize(dataDeserializer, dataSerializersModule) + } + } + .onFailure { + // If the overall result was successful then the failure _must_ have occurred during + // deserialization. Log the deserialization failure so it doesn't go unnoticed. + if (sequencedResult.ref.isSuccess) { + logger.warn(it) { "executeQuery() [rid=$requestId] decoding response data failed: $it" } + } + } + .onSuccess { + // Update the latest successful update. Set the value in a compare-and-swap loop to ensure + // that an older result does not clobber a newer one. + while (true) { + val latestSuccessful = latestSuccessfulUpdate.value + if ( + latestSuccessful.ref !== null && + sequencedResult.sequenceNumber <= latestSuccessful.ref.sequenceNumber + ) { + break + } + if ( + latestSuccessfulUpdate.compareAndSet( + latestSuccessful, + NullableReference(SequencedReference(sequencedResult.sequenceNumber, it)) + ) + ) { + break + } + } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/AnyValueSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/AnyValueSerializer.kt new file mode 100644 index 00000000000..28c5ffb3953 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/AnyValueSerializer.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.serializers + +import com.google.firebase.dataconnect.AnyValue +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * An implementation of [KSerializer] for serializing and deserializing [AnyValue] objects. + * + * Note that this is _not_ a generic serializer, but is only useful in the Data Connect SDK. + */ +public object AnyValueSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.google.firebase.dataconnect.AnyValue") {} + + override fun serialize(encoder: Encoder, value: AnyValue): Unit = unsupported() + + override fun deserialize(decoder: Decoder): AnyValue = unsupported() + + private fun unsupported(): Nothing = + throw UnsupportedOperationException( + "The AnyValueSerializer class cannot actually be used;" + + " it is merely a sentinel that gets special treatment during Data Connect serialization" + ) +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/DateSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/DateSerializer.kt new file mode 100644 index 00000000000..6ff9cb79c13 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/DateSerializer.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.serializers + +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.TimeZone +import java.util.regex.Matcher +import java.util.regex.Pattern +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * An implementation of [KSerializer] for serializing and deserializing [Date] objects in the wire + * format expected by the Firebase Data Connect backend. + */ +public object DateSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Date) { + val calendar = GregorianCalendar(TimeZone.getTimeZone("UTC")) + calendar.time = value + + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + 1 + val day = calendar.get(Calendar.DAY_OF_MONTH) + + val serializedDate = + "$year".padStart(4, '0') + '-' + "$month".padStart(2, '0') + '-' + "$day".padStart(2, '0') + encoder.encodeString(serializedDate) + } + + override fun deserialize(decoder: Decoder): Date { + val serializedDate = decoder.decodeString() + + val matcher = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$").matcher(serializedDate) + require(matcher.matches()) { "date does not match regular expression: ${matcher.pattern()}" } + + fun Matcher.groupToIntIgnoringLeadingZeroes(index: Int): Int { + val groupText = group(index)!!.trimStart('0') + return if (groupText.isEmpty()) 0 else groupText.toInt() + } + + val year = matcher.groupToIntIgnoringLeadingZeroes(1) + val month = matcher.groupToIntIgnoringLeadingZeroes(2) + val day = matcher.groupToIntIgnoringLeadingZeroes(3) + + return GregorianCalendar(TimeZone.getTimeZone("UTC")) + .apply { + set(year, month - 1, day, 0, 0, 0) + set(Calendar.MILLISECOND, 0) + } + .time + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializer.kt new file mode 100644 index 00000000000..729cba4c230 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializer.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.serializers + +import com.google.firebase.Timestamp +import java.text.DateFormat +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +// Googlers see go/firemat:timestamps for specifications. + +/** + * An implementation of [KSerializer] for serializing and deserializing [Timestamp] objects in the + * wire format expected by the Firebase Data Connect backend. + */ +public object TimestampSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Timestamp", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Timestamp) { + val rfc3339String = TimestampSerializerImpl.timestampToString(value) + encoder.encodeString(rfc3339String) + } + + override fun deserialize(decoder: Decoder): Timestamp { + val rfc3339String = decoder.decodeString() + return TimestampSerializerImpl.timestampFromString(rfc3339String) + } +} + +internal object TimestampSerializerImpl { + + private val threadLocalDateFormatter = + object : ThreadLocal() { + override fun initialValue(): SimpleDateFormat { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + return dateFormat + } + } + + private val dateFormatter: DateFormat + get() = threadLocalDateFormatter.get()!! + + // TODO: Replace this implementation with Instant.parse() once minSdkVersion is bumped to at + // least 26 (Build.VERSION_CODES.O). + fun timestampFromString(str: String): Timestamp { + val strUppercase = str.uppercase() + + // If the timestamp string is 1985-04-12T23:20:50.123456789-07:00, the time-secfrac part + // (.123456789) is optional. And time-offset part can either be Z or +xx:xx or -xx:xx. + val regex = + Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{0,9})?(Z|[+-]\\d{2}:\\d{2})$") + + require(strUppercase.matches(regex)) { + "Value does not conform to the RFC3339 specification with up to 9 digits of time-secfrac precision (str=$str)." + } + + val position = ParsePosition(0) + val seconds = run { + val date = dateFormatter.parse(strUppercase, position) + requireNotNull(date) + require(position.index == 19) { + "position.index=${position.index}, but expected 19 (str=$str)" + } + Timestamp(date).seconds + } + + // For time-secfrac part, when running against different databases, this precision might change, + // and server will truncate it to 0/3/6 digits precision without throwing an error. + var nanoseconds = 0 + // Parse the nanoseconds. + if (strUppercase[position.index] == '.') { + val nanoStrStart = ++position.index + // We don't check for boundary since the string has pass the regex test. + while (strUppercase[position.index].isDigit()) { + position.index++ + } + val nanosecondsStr = strUppercase.substring(nanoStrStart, position.index) + nanoseconds = nanosecondsStr.padEnd(9, '0').toInt() + } + + if (strUppercase[position.index] == 'Z') { + return Timestamp(seconds, nanoseconds) + } + + // Parse the +xx:xx or -xx:xx time-offset part. + val addTimeDiffer = strUppercase[position.index] == '+' + val hours = strUppercase.substring(position.index + 1, position.index + 3).toInt() + val minutes = strUppercase.substring(position.index + 4, position.index + 6).toInt() + val timeZoneDiffer = hours * 3600 + minutes * 60 + return Timestamp(seconds + if (addTimeDiffer) -timeZoneDiffer else timeZoneDiffer, nanoseconds) + } + + /** + * The expected serialized timestamp format is RFC3339: `yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'`, it + * can be constructed by two parts. First, we use `dateFormatter` to serialize seconds. Then, we + * pad nanoseconds into a 9 digits string. + */ + fun timestampToString(timestamp: Timestamp): String { + val serializedSecond = dateFormatter.format(Date(timestamp.seconds * 1000)) + val serializedNano = timestamp.nanoseconds.toString().padStart(9, '0') + return "$serializedSecond.${serializedNano}Z" + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/UUIDSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/UUIDSerializer.kt new file mode 100644 index 00000000000..41418ca975e --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/UUIDSerializer.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.serializers + +import java.util.UUID +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * An implementation of [KSerializer] for serializing and deserializing [UUID] objects in the wire + * format expected by the Firebase Data Connect backend. + */ +public object UUIDSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UUID) { + val uuidString = UUIDSerializerImpl.serialize(value) + encoder.encodeString(uuidString) + } + + override fun deserialize(decoder: Decoder): UUID { + val decodedString = decoder.decodeString() + return UUIDSerializerImpl.deserialize(decodedString) + } +} + +internal object UUIDSerializerImpl { + internal fun serialize(value: UUID): String { + // Remove dashes from the UUID since the server will remove them anyways (see cl/629562890). + return value.toString().replace("-", "") + } + + internal fun deserialize(decodedString: String): UUID { + require(decodedString.length == 32) { + "invalid UUID string: $decodedString (length=${decodedString.length}, expected=32)" + } + + // Insert dashes into the UUID string since the server will remove them (see cl/629562890). + val decodedStringWithDashes = buildString { + append(decodedString, 0, 8) + append("-") + append(decodedString, 8, 12) + append("-") + append(decodedString, 12, 16) + append("-") + append(decodedString, 16, 20) + append("-") + append(decodedString, 20, decodedString.length) + } + check(decodedStringWithDashes.length == 36) { + "internal error: decodedStringWithDashes.length==${decodedStringWithDashes.length}, " + + "but expected 36 (decodedStringWithDashes=\"${decodedStringWithDashes}\")" + } + + return UUID.fromString(decodedStringWithDashes) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/AlphanumericStringUtil.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/AlphanumericStringUtil.kt new file mode 100644 index 00000000000..81e46fb10b5 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/AlphanumericStringUtil.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.util + +/** + * Holder for "global" functions related to [ProtoStructValueDecoder]. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates a + * AlphanumericStringUtilKt Java class with public visibility, which pollutes the public API. Using + * an "internal" object, instead, to gather together the top-level functions avoids this public API + * pollution. + */ +internal object AlphanumericStringUtil { + + // NOTE: `ALPHANUMERIC_ALPHABET` MUST have a length of 32 (since 2^5=32). This allows encoding 5 + // bits as a single digit from this alphabet. Note that some numbers and letters were removed, + // especially those that can look similar in different fonts, like '1', 'l', and 'i'. + private const val ALPHANUMERIC_ALPHABET = "23456789abcdefghjkmnopqrstuvwxyz" + + /** + * Converts this byte array to a base-36 string, which uses the 26 letters from the English + * alphabet and the 10 numeric digits. + */ + fun ByteArray.toAlphaNumericString(): String = buildString { + val numBits = size * 8 + for (bitIndex in 0 until numBits step 5) { + val byteIndex = bitIndex.div(8) + val bitOffset = bitIndex.rem(8) + val b = this@toAlphaNumericString[byteIndex].toUByte().toInt() + + val intValue = + if (bitOffset <= 3) { + b shr (3 - bitOffset) + } else { + val upperBits = + when (bitOffset) { + 4 -> b and 0x0f + 5 -> b and 0x07 + 6 -> b and 0x03 + 7 -> b and 0x01 + else -> error("internal error: invalid bitOffset: $bitOffset") + } + if (byteIndex + 1 == size) { + upperBits + } else { + val b2 = this@toAlphaNumericString[byteIndex + 1].toUByte().toInt() + when (bitOffset) { + 4 -> ((b2 shr 7) and 0x01) or (upperBits shl 1) + 5 -> ((b2 shr 6) and 0x03) or (upperBits shl 2) + 6 -> ((b2 shr 5) and 0x07) or (upperBits shl 3) + 7 -> ((b2 shr 4) and 0x0f) or (upperBits shl 4) + else -> error("internal error: invalid bitOffset: $bitOffset") + } + } + } + + append(ALPHANUMERIC_ALPHABET[intValue and 0x1f]) + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullOutputStream.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullOutputStream.kt new file mode 100644 index 00000000000..14e09a43c35 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullOutputStream.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.util + +import java.io.OutputStream + +internal object NullOutputStream : OutputStream() { + override fun write(b: Int) {} + override fun write(b: ByteArray?) {} + override fun write(b: ByteArray?, off: Int, len: Int) {} +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullableReference.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullableReference.kt new file mode 100644 index 00000000000..9a67442e263 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullableReference.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.util + +internal class NullableReference(val ref: T? = null) { + override fun equals(other: Any?) = (other is NullableReference<*>) && other.ref == ref + override fun hashCode() = ref?.hashCode() ?: 0 + override fun toString() = ref?.toString() ?: "null" +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt new file mode 100644 index 00000000000..1dbb0ea0ec5 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt @@ -0,0 +1,591 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.dataconnect.util + +import com.google.firebase.dataconnect.AnyValue +import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeBoolean +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeByte +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeChar +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeDouble +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeEnum +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeFloat +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeInt +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeList +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeLong +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeNull +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeShort +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeString +import com.google.firebase.dataconnect.util.ProtoDecoderUtil.decodeStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toAny +import com.google.protobuf.ListValue +import com.google.protobuf.NullValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import com.google.protobuf.Value.KindCase +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.descriptors.elementNames +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule + +/** + * Holder for "global" functions related to [ProtoStructValueDecoder]. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates a + * ProtoStructDecoderKt, ProtoUtilKt, etc. Java class with public visibility, which pollutes the + * public API. Using an "internal" object, instead, to gather together the top-level functions + * avoids this public API pollution. + */ +private object ProtoDecoderUtil { + fun decode(value: Value, path: String?, expectedKindCase: KindCase, block: (Value) -> T): T = + if (value.kindCase != expectedKindCase) { + throw SerializationException( + (if (path === null) "" else "decoding \"$path\" failed: ") + + "expected $expectedKindCase, but got ${value.kindCase} (${value.toAny()})" + ) + } else { + block(value) + } + + fun decodeBoolean(value: Value, path: String?): Boolean = + decode(value, path, KindCase.BOOL_VALUE) { it.boolValue } + + fun decodeByte(value: Value, path: String?): Byte = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toByte() } + + fun decodeChar(value: Value, path: String?): Char = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toChar() } + + fun decodeDouble(value: Value, path: String?): Double = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue } + + fun decodeEnum(value: Value, path: String?): Int = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt() } + + fun decodeFloat(value: Value, path: String?): Float = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toFloat() } + + fun decodeString(value: Value, path: String?): String = + decode(value, path, KindCase.STRING_VALUE) { it.stringValue } + + fun decodeStruct(value: Value, path: String?): Struct = + decode(value, path, KindCase.STRUCT_VALUE) { it.structValue } + + fun decodeList(value: Value, path: String?): ListValue = + decode(value, path, KindCase.LIST_VALUE) { it.listValue } + + fun decodeNull(value: Value, path: String?): NullValue = + decode(value, path, KindCase.NULL_VALUE) { it.nullValue } + + fun decodeInt(value: Value, path: String?): Int = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt() } + + fun decodeLong(value: Value, path: String?): Long = + decode(value, path, KindCase.STRING_VALUE) { it.stringValue.toLong() } + + fun decodeShort(value: Value, path: String?): Short = + decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toInt().toShort() } +} + +internal class ProtoValueDecoder( + internal val valueProto: Value, + private val path: String?, + override val serializersModule: SerializersModule +) : Decoder { + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = + when (val kind = descriptor.kind) { + is StructureKind.CLASS -> + ProtoStructValueDecoder(decodeStruct(valueProto, path), path, serializersModule) + is StructureKind.LIST -> + ProtoListValueDecoder(decodeList(valueProto, path), path, serializersModule) + is StructureKind.MAP -> + ProtoMapValueDecoder(decodeStruct(valueProto, path), path, serializersModule) + is StructureKind.OBJECT -> ProtoObjectValueDecoder(path, serializersModule) + else -> throw IllegalArgumentException("unsupported SerialKind: ${kind::class.qualifiedName}") + } + + override fun decodeBoolean() = decodeBoolean(valueProto, path) + + override fun decodeByte() = decodeByte(valueProto, path) + + override fun decodeChar() = decodeChar(valueProto, path) + + override fun decodeDouble() = decodeDouble(valueProto, path) + + override fun decodeEnum(enumDescriptor: SerialDescriptor) = decodeEnum(valueProto, path) + + override fun decodeFloat() = decodeFloat(valueProto, path) + + override fun decodeInline(descriptor: SerialDescriptor) = + ProtoValueDecoder(valueProto, path, serializersModule) + + override fun decodeInt(): Int = decodeInt(valueProto, path) + + override fun decodeLong() = decodeLong(valueProto, path) + + override fun decodeShort() = decodeShort(valueProto, path) + + override fun decodeString() = decodeString(valueProto, path) + + override fun decodeNotNullMark() = !valueProto.hasNullValue() + + override fun decodeNull(): Nothing? { + decodeNull(valueProto, path) + return null + } +} + +private class ProtoStructValueDecoder( + private val struct: Struct, + private val path: String?, + override val serializersModule: SerializersModule +) : CompositeDecoder { + + override fun endStructure(descriptor: SerialDescriptor) {} + + @Volatile private lateinit var elementIndexes: Iterator + + private fun getOrInitializeElementIndexes(descriptor: SerialDescriptor): Iterator { + if (!::elementIndexes.isInitialized) { + val names = + buildSet { + addAll(struct.fieldsMap.keys) + addAll(descriptor.elementNames) + } + elementIndexes = names.map(descriptor::getElementIndex).sorted().iterator() + } + + return elementIndexes + } + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + val indexes = getOrInitializeElementIndexes(descriptor) + return if (indexes.hasNext()) indexes.next() else CompositeDecoder.DECODE_DONE + } + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeBoolean) + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeByte) + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeChar) + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeDouble) + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeFloat) + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index) { valueProto, elementPath -> + ProtoValueDecoder(valueProto, elementPath, serializersModule) + } + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeInt) + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeLong) + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeShort) + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(descriptor, index, ProtoDecoderUtil::decodeString) + + private fun decodeValueElement( + descriptor: SerialDescriptor, + index: Int, + block: (Value, String?) -> T + ): T { + val elementName = descriptor.getElementName(index) + val elementPath = elementPathForName(elementName) + val elementKind = descriptor.getElementDescriptor(index).kind + + val valueProto = + struct.fieldsMap[elementName] + ?: throw SerializationException("element \"$elementPath\" missing (expected $elementKind)") + + return block(valueProto, elementPath) + } + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T { + if (previousValue !== null) { + return previousValue + } + + val elementName = descriptor.getElementName(index) + val elementPath = elementPathForName(elementName) + val elementKind = descriptor.getElementDescriptor(index).kind + + val valueProto = + struct.fieldsMap[elementName] + ?: if (elementKind is StructureKind.OBJECT) Value.getDefaultInstance() + else throw SerializationException("element \"$elementPath\" missing; expected $elementKind") + + return when (deserializer) { + is AnyValueSerializer -> { + @Suppress("UNCHECKED_CAST") + AnyValue(valueProto) as T + } + else -> { + val protoValueDecoder = ProtoValueDecoder(valueProto, elementPath, serializersModule) + deserializer.deserialize(protoValueDecoder) + } + } + } + + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T? { + val elementName = descriptor.getElementName(index) + return if (previousValue !== null) { + previousValue + } else if (!struct.containsFields(elementName)) { + null + } else if (struct.getFieldsOrThrow(elementName).hasNullValue()) { + null + } else { + decodeSerializableElement(descriptor, index, deserializer, previousValue = null) + } + } + + private fun elementPathForName(elementName: String) = + if (path === null) elementName else "${path}.${elementName}" +} + +private class ProtoListValueDecoder( + private val list: ListValue, + private val path: String?, + override val serializersModule: SerializersModule +) : CompositeDecoder { + + override fun endStructure(descriptor: SerialDescriptor) {} + + private val elementIndexes: IntIterator = list.valuesList.indices.iterator() + + override fun decodeElementIndex(descriptor: SerialDescriptor) = + if (elementIndexes.hasNext()) elementIndexes.next() else CompositeDecoder.DECODE_DONE + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeBoolean) + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeByte) + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeChar) + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeDouble) + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeFloat) + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index) { protoValue, elementPath -> + ProtoValueDecoder(protoValue, elementPath, serializersModule) + } + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeInt) + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeLong) + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeShort) + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeString) + + private inline fun decodeValueElement(index: Int, block: (Value, String?) -> T): T = + block(list.valuesList[index], elementPathForIndex(index)) + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T = + if (previousValue !== null) { + previousValue + } else if (deserializer is AnyValueSerializer) { + @Suppress("UNCHECKED_CAST") + AnyValue(list.valuesList[index]) as T + } else { + deserializer.deserialize( + ProtoValueDecoder(list.valuesList[index], elementPathForIndex(index), serializersModule) + ) + } + + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T? = + if (previousValue !== null) { + previousValue + } else if (list.valuesList[index].hasNullValue()) { + null + } else { + decodeSerializableElement(descriptor, index, deserializer, previousValue = null) + } + + private fun elementPathForIndex(index: Int) = if (path === null) "[$index]" else "${path}[$index]" + + override fun toString() = "ProtoListValueDecoder{path=$path, size=${list.valuesList.size}" +} + +private class ProtoMapValueDecoder( + private val struct: Struct, + private val path: String?, + override val serializersModule: SerializersModule +) : CompositeDecoder { + + override fun decodeSequentially() = true + + override fun decodeCollectionSize(descriptor: SerialDescriptor) = struct.fieldsCount + + override fun endStructure(descriptor: SerialDescriptor) {} + + private val structEntries: List> = struct.fieldsMap.entries.toList() + private val elementIndexes: IntIterator = (0 until structEntries.size * 2).iterator() + + private fun structEntryByElementIndex(index: Int): Map.Entry = + structEntries[index / 2] + + override fun decodeElementIndex(descriptor: SerialDescriptor) = + if (elementIndexes.hasNext()) elementIndexes.next() else CompositeDecoder.DECODE_DONE + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeBoolean) + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeByte) + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeChar) + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeDouble) + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeFloat) + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index) { valueProto, elementPath -> + ProtoValueDecoder(valueProto, elementPath, serializersModule) + } + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeInt) + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeLong) + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int) = + decodeValueElement(index, ProtoDecoderUtil::decodeShort) + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int) = + if (index % 2 == 0) { + structEntryByElementIndex(index).key + } else { + decodeValueElement(index, ProtoDecoderUtil::decodeString) + } + + private inline fun decodeValueElement(index: Int, block: (Value, String?) -> T): T { + require(index % 2 != 0) { "invalid value index: $index" } + val value = structEntryByElementIndex(index).value + val elementPath = elementPathForIndex(index) + return block(value, elementPath) + } + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T = + if (previousValue !== null) { + previousValue + } else { + decodeSerializableElement(index, deserializer) + } + + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ): T? { + if (previousValue !== null) { + return previousValue + } + + if (index % 2 != 0) { + val structEntry = structEntryByElementIndex(index) + if (structEntry.value.hasNullValue()) { + return null + } + } + + return decodeSerializableElement(index, deserializer) + } + + private fun decodeSerializableElement( + index: Int, + deserializer: DeserializationStrategy + ): T { + val structEntry = structEntryByElementIndex(index) + val elementPath = elementPathForIndex(index) + + val elementDecoder = + if (index % 2 == 0) { + MapKeyDecoder(structEntry.key, elementPath, serializersModule) + } else { + ProtoValueDecoder(structEntry.value, elementPath, serializersModule) + } + + return deserializer.deserialize(elementDecoder) + } + + private fun elementPathForIndex(index: Int): String { + val structEntry = structEntryByElementIndex(index) + val key = structEntry.key + return if (index % 2 == 0) { + if (path === null) "[$key]" else "${path}[$key]" + } else { + if (path === null) "[$key].value" else "${path}[$key].value" + } + } + + override fun toString() = "ProtoMapValueDecoder{path=$path, size=${struct.fieldsCount}" +} + +private class ProtoObjectValueDecoder( + val path: String?, + override val serializersModule: SerializersModule +) : CompositeDecoder { + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun decodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ) = notSupported() + + override fun decodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T? + ) = notSupported() + + private fun notSupported(): Nothing = + throw UnsupportedOperationException( + "The only valid method calls on ProtoObjectValueDecoder are " + + "decodeElementIndex() and endStructure()" + ) + + override fun decodeElementIndex(descriptor: SerialDescriptor) = CompositeDecoder.DECODE_DONE + + override fun endStructure(descriptor: SerialDescriptor) {} + + override fun toString() = "ProtoObjectValueDecoder{path=$path}" +} + +private class MapKeyDecoder( + val key: String, + val path: String, + override val serializersModule: SerializersModule +) : Decoder { + + override fun decodeString() = key + + override fun beginStructure(descriptor: SerialDescriptor) = notSupported() + + override fun decodeBoolean() = notSupported() + + override fun decodeByte() = notSupported() + + override fun decodeChar() = notSupported() + + override fun decodeDouble() = notSupported() + + override fun decodeEnum(enumDescriptor: SerialDescriptor) = notSupported() + + override fun decodeFloat() = notSupported() + + override fun decodeInline(descriptor: SerialDescriptor) = notSupported() + + override fun decodeInt() = notSupported() + + override fun decodeLong() = notSupported() + + override fun decodeNotNullMark() = notSupported() + + override fun decodeNull() = notSupported() + + override fun decodeShort() = notSupported() + + private fun notSupported(): Nothing = + throw UnsupportedOperationException( + "The only valid method call on MapKeyDecoder is decodeString()" + ) + + override fun toString() = "MapKeyDecoder{path=$path}" +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt new file mode 100644 index 00000000000..431f50159c0 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt @@ -0,0 +1,445 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.dataconnect.util + +import com.google.firebase.dataconnect.AnyValue +import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import com.google.protobuf.ListValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer + +internal class ProtoValueEncoder( + private val path: String?, + override val serializersModule: SerializersModule, + val onValue: (Value) -> Unit +) : Encoder { + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + when (val kind = descriptor.kind) { + is StructureKind.CLASS -> ProtoStructValueEncoder(path, serializersModule, onValue) + is StructureKind.LIST -> ProtoListValueEncoder(path, serializersModule, onValue) + is StructureKind.MAP -> ProtoMapValueEncoder(path, serializersModule, onValue) + is StructureKind.OBJECT -> ProtoObjectValueEncoder + else -> throw IllegalArgumentException("unsupported SerialKind: ${kind::class.qualifiedName}") + } + + override fun encodeBoolean(value: Boolean) { + onValue(value.toValueProto()) + } + + override fun encodeByte(value: Byte) { + onValue(value.toValueProto()) + } + + override fun encodeChar(value: Char) { + onValue(value.toValueProto()) + } + + override fun encodeDouble(value: Double) { + onValue(value.toValueProto()) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + onValue(index.toValueProto()) + } + + override fun encodeFloat(value: Float) { + onValue(value.toValueProto()) + } + + override fun encodeInline(descriptor: SerialDescriptor) = this + + override fun encodeInt(value: Int) { + onValue(value.toValueProto()) + } + + override fun encodeLong(value: Long) { + onValue(value.toValueProto()) + } + + @ExperimentalSerializationApi + override fun encodeNull() { + onValue(nullProtoValue) + } + + @ExperimentalSerializationApi + override fun encodeNotNullMark() { + encodeBoolean(true) + } + + override fun encodeShort(value: Short) { + onValue(value.toValueProto()) + } + + override fun encodeString(value: String) { + onValue(value.toValueProto()) + } + + override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { + when (serializer) { + is AnyValueSerializer -> { + val anyValue = value as AnyValue + onValue(anyValue.protoValue) + } + else -> super.encodeSerializableValue(serializer, value) + } + } +} + +private abstract class ProtoCompositeValueEncoder( + private val path: String?, + override val serializersModule: SerializersModule, + private val onValue: (Value) -> Unit +) : CompositeEncoder { + private val valueByKey = mutableMapOf() + + private fun putValue(descriptor: SerialDescriptor, index: Int, value: Value) { + val key = keyOf(descriptor, index) + valueByKey[key] = value + } + + protected abstract fun keyOf(descriptor: SerialDescriptor, index: Int): K + protected abstract fun formattedKeyForElementPath(key: K): String + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder { + throw UnsupportedOperationException("inline is not implemented yet") + } + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) { + putValue(descriptor, index, value.toValueProto()) + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + putValue(descriptor, index, value.toValueProto()) + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T? + ) { + val key = keyOf(descriptor, index) + val encoder = + ProtoValueEncoder(elementPathForKey(key), serializersModule) { valueByKey[key] = it } + encoder.encodeNullableSerializableValue(serializer, value) + } + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T + ) { + val key = keyOf(descriptor, index) + val encoder = + ProtoValueEncoder(elementPathForKey(key), serializersModule) { valueByKey[key] = it } + encoder.encodeSerializableValue(serializer, value) + } + + override fun endStructure(descriptor: SerialDescriptor) { + onValue(Value.newBuilder().also { populate(descriptor, it, valueByKey) }.build()) + } + + private fun elementPathForKey(key: K): String = + formattedKeyForElementPath(key).let { if (path === null) it else "$path$it" } + + protected abstract fun populate( + descriptor: SerialDescriptor, + valueBuilder: Value.Builder, + valueByKey: Map + ) +} + +private class ProtoListValueEncoder( + private val path: String?, + serializersModule: SerializersModule, + onValue: (Value) -> Unit +) : ProtoCompositeValueEncoder(path, serializersModule, onValue) { + + override fun keyOf(descriptor: SerialDescriptor, index: Int) = index + + override fun formattedKeyForElementPath(key: Int) = "[$key]" + + override fun populate( + descriptor: SerialDescriptor, + valueBuilder: Value.Builder, + valueByKey: Map + ) { + valueBuilder.setListValue( + ListValue.newBuilder().also { listValueBuilder -> + for (i in 0 until valueByKey.size) { + listValueBuilder.addValues( + valueByKey[i] + ?: throw SerializationException( + "$path: list value missing at index $i" + + " (have ${valueByKey.size} indexes:" + + " ${valueByKey.keys.sorted().joinToString()})" + ) + ) + } + } + ) + } +} + +private class ProtoStructValueEncoder( + path: String?, + serializersModule: SerializersModule, + onValue: (Value) -> Unit +) : ProtoCompositeValueEncoder(path, serializersModule, onValue) { + + override fun keyOf(descriptor: SerialDescriptor, index: Int) = descriptor.getElementName(index) + + override fun formattedKeyForElementPath(key: String) = ".$key" + + override fun populate( + descriptor: SerialDescriptor, + valueBuilder: Value.Builder, + valueByKey: Map + ) { + valueBuilder.setStructValue( + Struct.newBuilder().also { structBuilder -> + valueByKey.forEach { (key, value) -> + if (value.hasNullValue()) { + structBuilder.putFields(key, nullProtoValue) + } else { + structBuilder.putFields(key, value) + } + } + } + ) + } +} + +private class ProtoMapValueEncoder( + private val path: String?, + override val serializersModule: SerializersModule, + private val onValue: (Value) -> Unit +) : CompositeEncoder { + + private val keyByIndex = mutableMapOf() + private val valueByIndex = mutableMapOf() + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder { + throw UnsupportedOperationException("inline is not implemented yet") + } + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T? + ) { + if (index % 2 == 0) { + require(value is String) { + "even indexes must be a String, but got index=$index value=$value" + } + keyByIndex[index] = value + return + } + + val protoValue = + if (value === null) { + null + } else { + val subPath = keyByIndex[index - 1] ?: "$index" + var encodedValue: Value? = null + val encoder = ProtoValueEncoder(subPath, serializersModule) { encodedValue = it } + encoder.encodeNullableSerializableValue(serializer, value) + requireNotNull(encodedValue) { "ProtoValueEncoder should have produced a value" } + encodedValue + } + valueByIndex[index] = protoValue ?: nullProtoValue + } + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T + ) { + if (index % 2 == 0) { + require(value is String) { + "even indexes must be a String, but got index=$index value=$value" + } + keyByIndex[index] = value + } else { + val subPath = keyByIndex[index - 1] ?: "$index" + val encoder = ProtoValueEncoder(subPath, serializersModule) { valueByIndex[index] = it } + encoder.encodeSerializableValue(serializer, value) + } + } + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) { + require(index % 2 != 0) { "invalid index: $index (must be odd)" } + valueByIndex[index] = value.toValueProto() + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + if (index % 2 != 0) { + valueByIndex[index] = value.toValueProto() + } else { + keyByIndex[index] = value + } + } + + override fun endStructure(descriptor: SerialDescriptor) { + var i = 0 + val structBuilder = Struct.newBuilder() + while (keyByIndex.containsKey(i)) { + val key = keyByIndex[i++] + val value = valueByIndex[i++] + structBuilder.putFields(key, value) + } + onValue(structBuilder.build().toValueProto()) + } +} + +private object ProtoObjectValueEncoder : CompositeEncoder { + override val serializersModule = EmptySerializersModule() + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) = + notSupported() + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) = + notSupported() + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) = + notSupported() + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) = + notSupported() + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) = + notSupported() + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int) = notSupported() + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) = + notSupported() + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) = + notSupported() + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T? + ) = notSupported() + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T + ) = notSupported() + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) = + notSupported() + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) = + notSupported() + + private fun notSupported(): Nothing = + throw UnsupportedOperationException( + "The only valid method call on ProtoObjectValueEncoder is endStructure()" + ) + + override fun endStructure(descriptor: SerialDescriptor) {} +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt new file mode 100644 index 00000000000..4cfe1ffd288 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt @@ -0,0 +1,517 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.util + +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.toDataConnectError +import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import com.google.protobuf.ListValue +import com.google.protobuf.NullValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import com.google.protobuf.Value.KindCase +import com.google.protobuf.listValueOrNull +import com.google.protobuf.structValueOrNull +import google.firebase.dataconnect.proto.EmulatorInfo +import google.firebase.dataconnect.proto.EmulatorIssue +import google.firebase.dataconnect.proto.EmulatorIssuesResponse +import google.firebase.dataconnect.proto.ExecuteMutationRequest +import google.firebase.dataconnect.proto.ExecuteMutationResponse +import google.firebase.dataconnect.proto.ExecuteQueryRequest +import google.firebase.dataconnect.proto.ExecuteQueryResponse +import google.firebase.dataconnect.proto.ServiceInfo +import java.io.BufferedWriter +import java.io.CharArrayWriter +import java.io.DataOutputStream +import java.security.DigestOutputStream +import java.security.MessageDigest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer + +/** + * Holder for "global" functions related to protocol buffers. + * + * Technically, these functions _could_ be defined as free functions; however, doing so creates a + * ProtoStructEncoderKt, ProtoUtilKt, etc. Java class with public visibility, which pollutes the + * public API. Using an "internal" object, instead, to gather together the top-level functions + * avoids this public API pollution. + */ +internal object ProtoUtil { + + /** Calculates a SHA-512 digest of a [Struct]. */ + fun Struct.calculateSha512(): ByteArray = + Value.newBuilder().setStructValue(this).build().calculateSha512() + + /** Calculates a SHA-512 digest of a [Value]. */ + fun Value.calculateSha512(): ByteArray { + val digest = MessageDigest.getInstance("SHA-512") + val out = DataOutputStream(DigestOutputStream(NullOutputStream, digest)) + + val calculateDigest = + DeepRecursiveFunction { + val kind = it.kindCase + out.writeInt(kind.ordinal) + + when (kind) { + KindCase.NULL_VALUE -> { + /* nothing to write for null */ + } + KindCase.BOOL_VALUE -> out.writeBoolean(it.boolValue) + KindCase.NUMBER_VALUE -> out.writeDouble(it.numberValue) + KindCase.STRING_VALUE -> out.writeUTF(it.stringValue) + KindCase.LIST_VALUE -> + it.listValue.valuesList.forEachIndexed { index, elementValue -> + out.writeInt(index) + callRecursive(elementValue) + } + KindCase.STRUCT_VALUE -> + it.structValue.fieldsMap.entries + .sortedBy { (key, _) -> key } + .forEach { (key, elementValue) -> + out.writeUTF(key) + callRecursive(elementValue) + } + else -> throw IllegalArgumentException("unsupported kind: $kind") + } + + out.writeInt(kind.ordinal) + } + + calculateDigest(this) + + return digest.digest() + } + + fun Boolean.toValueProto(): Value = Value.newBuilder().setBoolValue(this).build() + + fun Byte.toValueProto(): Value = toInt().toValueProto() + + fun Char.toValueProto(): Value = code.toValueProto() + + fun Double.toValueProto(): Value = Value.newBuilder().setNumberValue(this).build() + + fun Float.toValueProto(): Value = toDouble().toValueProto() + + fun Int.toValueProto(): Value = toDouble().toValueProto() + + fun Long.toValueProto(): Value = toString().toValueProto() + + fun Short.toValueProto(): Value = toInt().toValueProto() + + fun String.toValueProto(): Value = Value.newBuilder().setStringValue(this).build() + + fun ListValue.toValueProto(): Value = Value.newBuilder().setListValue(this).build() + + fun Struct.toValueProto(): Value = Value.newBuilder().setStructValue(this).build() + + val nullProtoValue: Value + get() { + return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build() + } + + /** A more convenient builder for [Struct] than [com.google.protobuf.struct]. */ + fun buildStructProto( + initialValues: Struct? = null, + block: StructProtoBuilder.() -> Unit + ): Struct = StructProtoBuilder(initialValues).apply(block).build() + + /** Generates and returns a string similar to [Struct.toString] but more compact. */ + fun Struct.toCompactString(keySortSelector: ((String) -> String)? = null): String = + Value.newBuilder().setStructValue(this).build().toCompactString(keySortSelector) + + /** Generates and returns a string similar to [Value.toString] but more compact. */ + fun Value.toCompactString(keySortSelector: ((String) -> String)? = null): String { + val charArrayWriter = CharArrayWriter() + val out = BufferedWriter(charArrayWriter) + var indent = 0 + + fun BufferedWriter.writeIndent() { + repeat(indent * 2) { write(" ") } + } + + val calculateCompactString = + DeepRecursiveFunction { + when (val kind = it.kindCase) { + KindCase.NULL_VALUE -> out.write("null") + KindCase.BOOL_VALUE -> out.write(if (it.boolValue) "true" else "false") + KindCase.NUMBER_VALUE -> out.write(it.numberValue.toString()) + KindCase.STRING_VALUE -> out.write("\"${it.stringValue}\"") + KindCase.LIST_VALUE -> { + out.write("[") + indent++ + it.listValue.valuesList.forEach { listElementValue -> + out.newLine() + out.writeIndent() + callRecursive(listElementValue) + } + indent-- + out.newLine() + out.writeIndent() + out.write("]") + } + KindCase.STRUCT_VALUE -> { + out.write("{") + indent++ + it.structValue.fieldsMap.entries + .sortedBy { (key, _) -> keySortSelector?.invoke(key) ?: key } + .forEach { (structElementKey, structElementValue) -> + out.newLine() + out.writeIndent() + out.write("$structElementKey: ") + callRecursive(structElementValue) + } + indent-- + out.newLine() + out.writeIndent() + out.write("}") + } + else -> throw IllegalArgumentException("unsupported kind: $kind") + } + } + + calculateCompactString(this) + + out.close() + return charArrayWriter.toString() + } + + fun ExecuteQueryRequest.toCompactString(): String = toStructProto().toCompactString() + + fun ExecuteQueryRequest.toStructProto(): Struct = buildStructProto { + put("name", name) + put("operationName", operationName) + if (hasVariables()) put("variables", variables) + } + + fun ExecuteQueryResponse.toCompactString(): String = toStructProto().toCompactString() + + fun ExecuteQueryResponse.toStructProto(): Struct = buildStructProto { + if (hasData()) put("data", data) + putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + } + + fun ExecuteMutationRequest.toCompactString(): String = toStructProto().toCompactString() + + fun ExecuteMutationRequest.toStructProto(): Struct = buildStructProto { + put("name", name) + put("operationName", operationName) + if (hasVariables()) put("variables", variables) + } + + fun ExecuteMutationResponse.toCompactString(): String = toStructProto().toCompactString() + + fun ExecuteMutationResponse.toStructProto(): Struct = buildStructProto { + if (hasData()) put("data", data) + putList("errors") { errorsList.forEach { add(it.toDataConnectError().toString()) } } + } + + fun EmulatorInfo.toStructProto(): Struct = buildStructProto { + put("version", version) + putList("services") { servicesList.forEach { add(it.toStructProto()) } } + } + + fun ServiceInfo.toStructProto(): Struct = buildStructProto { + put("service_id", serviceId) + put("connection_string", connectionString) + } + + fun EmulatorIssuesResponse.toStructProto(): Struct = buildStructProto { + putList("issues") { issuesList.forEach { add(it.toStructProto()) } } + } + + fun EmulatorIssue.toStructProto(): Struct = buildStructProto { + put("kind", kind.name) + put("severity", severity.name) + put("message", message) + } + + fun ListValue.toListOfAny(): List = valueToAnyMutualRecursion.anyFromListValue(this) + + fun Struct.toMap(): Map = valueToAnyMutualRecursion.anyValueFromStruct(this) + + fun Value.toAny(): Any? = valueToAnyMutualRecursion.anyValueFromValue(this) + + fun List.toValueProto(): Value { + val key = "y8czq9rh75" + return mapOf(key to this).toStructProto().getFieldsOrThrow(key) + } + + fun Map.toValueProto(): Value = + Value.newBuilder().setStructValue(toStructProto()).build() + + fun Map.toStructProto(): Struct = mapToStructProtoMutualRecursion.structForMap(this) + + private val mapToStructProtoMutualRecursion = + object { + val listValueForList: DeepRecursiveFunction, ListValue> = DeepRecursiveFunction { + val listValueProtoBuilder = ListValue.newBuilder() + it.forEach { value -> + listValueProtoBuilder.addValues( + when (value) { + null -> nullProtoValue + is Boolean -> value.toValueProto() + is Double -> value.toValueProto() + is String -> value.toValueProto() + is List<*> -> callRecursive(value).toValueProto() + is Map<*, *> -> structForMap.callRecursive(value).toValueProto() + else -> + throw IllegalArgumentException( + "unsupported type: ${value::class.qualifiedName}; " + + "supported types are: Boolean, Double, String, List, and Map" + ) + } + ) + } + listValueProtoBuilder.build() + } + + val structForMap: DeepRecursiveFunction, Struct> = DeepRecursiveFunction { + val structProtoBuilder = Struct.newBuilder() + it.entries.forEach { (untypedKey, value) -> + val key = + (untypedKey as? String) + ?: throw IllegalArgumentException( + "map keys must be string, but got: " + + if (untypedKey === null) "null" else untypedKey::class.qualifiedName + ) + structProtoBuilder.putFields( + key, + when (value) { + null -> nullProtoValue + is Double -> value.toValueProto() + is Boolean -> value.toValueProto() + is String -> value.toValueProto() + is List<*> -> listValueForList.callRecursive(value).toValueProto() + is Map<*, *> -> callRecursive(value).toValueProto() + else -> + throw IllegalArgumentException( + "unsupported type: ${value::class.qualifiedName}; " + + "supported types are: Boolean, Double, String, List, and Map" + ) + } + ) + } + structProtoBuilder.build() + } + } + + private val valueToAnyMutualRecursion = + object { + val anyFromListValue: DeepRecursiveFunction> = + DeepRecursiveFunction { listValue -> + buildList { + for (element in listValue.valuesList) { + add(anyValueFromValue.callRecursive(element)) + } + } + } + + val anyValueFromStruct: DeepRecursiveFunction> = + DeepRecursiveFunction { struct -> + buildMap { + for (entry in struct.fieldsMap) { + put(entry.key, anyValueFromValue.callRecursive(entry.value)) + } + } + } + + val anyValueFromValue: DeepRecursiveFunction = DeepRecursiveFunction { value -> + when (value.kindCase) { + KindCase.BOOL_VALUE -> value.boolValue + KindCase.NUMBER_VALUE -> value.numberValue + KindCase.STRING_VALUE -> value.stringValue + KindCase.LIST_VALUE -> anyFromListValue.callRecursive(value.listValue) + KindCase.STRUCT_VALUE -> anyValueFromStruct.callRecursive(value.structValue) + KindCase.NULL_VALUE -> null + else -> "ERROR: unsupported kindCase: ${value.kindCase}" + } + } + } + + inline fun encodeToStruct(value: T): Struct = + encodeToStruct(value, serializer(), serializersModule = null) + + fun encodeToStruct( + value: T, + serializer: SerializationStrategy, + serializersModule: SerializersModule? + ): Struct { + val valueProto = encodeToValue(value, serializer, serializersModule) + if (valueProto.kindCase == KindCase.KIND_NOT_SET) { + return Struct.getDefaultInstance() + } + require(valueProto.hasStructValue()) { + "encoding produced ${valueProto.kindCase}, " + + "but expected ${KindCase.STRUCT_VALUE} or ${KindCase.KIND_NOT_SET}" + } + return valueProto.structValue + } + + inline fun encodeToValue(value: T): Value = + encodeToValue(value, serializer(), serializersModule = null) + + fun encodeToValue( + value: T, + serializer: SerializationStrategy, + serializersModule: SerializersModule? + ): Value { + val values = mutableListOf() + ProtoValueEncoder(null, serializersModule ?: EmptySerializersModule(), values::add) + .encodeSerializableValue(serializer, value) + if (values.isEmpty()) { + return Value.getDefaultInstance() + } + require(values.size == 1) { + "encoding produced ${values.size} Value objects, but expected either 0 or 1" + } + return values.single() + } + + inline fun decodeFromStruct(struct: Struct): T = + decodeFromStruct(struct, serializer(), serializersModule = null) + + fun decodeFromStruct( + struct: Struct, + deserializer: DeserializationStrategy, + serializersModule: SerializersModule? + ): T { + val protoValue = Value.newBuilder().setStructValue(struct).build() + return decodeFromValue(protoValue, deserializer, serializersModule) + } + + inline fun decodeFromValue(value: Value): T = + decodeFromValue(value, serializer(), serializersModule = null) + + fun decodeFromValue( + value: Value, + deserializer: DeserializationStrategy, + serializersModule: SerializersModule? + ): T { + val decoder = + ProtoValueDecoder(value, path = null, serializersModule ?: EmptySerializersModule()) + return decoder.decodeSerializableValue(deserializer) + } +} + +@DslMarker internal annotation class StructProtoBuilderDslMarker + +@StructProtoBuilderDslMarker +internal class StructProtoBuilder(struct: Struct? = null) { + private val builder = struct?.toBuilder() ?: Struct.newBuilder() + + fun build(): Struct = builder.build() + + fun clear() { + builder.clearFields() + } + + fun remove(key: String) { + builder.removeFields(key) + } + + fun put(key: String, value: Double?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun put(key: String, value: Int?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun put(key: String, value: Boolean?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun put(key: String, value: String?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun put(key: String, value: ListValue?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun putList(key: String, block: ListValueProtoBuilder.() -> Unit) { + val initialValue = builder.getFieldsOrDefault(key, Value.getDefaultInstance()).listValueOrNull + builder.putFields(key, ListValueProtoBuilder(initialValue).apply(block).build().toValueProto()) + } + + fun put(key: String, value: Struct?) { + builder.putFields(key, value?.toValueProto() ?: nullProtoValue) + } + + fun putStruct(key: String, block: StructProtoBuilder.() -> Unit) { + val initialValue = builder.getFieldsOrDefault(key, Value.getDefaultInstance()).structValueOrNull + builder.putFields(key, StructProtoBuilder(initialValue).apply(block).build().toValueProto()) + } + + fun putNull(key: String) { + builder.putFields(key, nullProtoValue) + } +} + +@StructProtoBuilderDslMarker +internal class ListValueProtoBuilder(listValue: ListValue? = null) { + private val builder = listValue?.toBuilder() ?: ListValue.newBuilder() + + fun build(): ListValue = builder.build() + + fun clear() { + builder.clearValues() + } + + fun removeAt(index: Int) { + builder.removeValues(index) + } + + fun add(value: Double?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun add(value: Int?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun add(value: Boolean?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun add(value: String?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun add(value: ListValue?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun addList(block: ListValueProtoBuilder.() -> Unit) { + builder.addValues(ListValueProtoBuilder().apply(block).build().toValueProto()) + } + + fun add(value: Struct?) { + builder.addValues(value?.toValueProto() ?: nullProtoValue) + } + + fun addStruct(block: StructProtoBuilder.() -> Unit) { + builder.addValues(StructProtoBuilder().apply(block).build().toValueProto()) + } + + fun addNull() { + builder.addValues(nullProtoValue) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ReferenceCounted.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ReferenceCounted.kt new file mode 100644 index 00000000000..1d0e1233a01 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ReferenceCounted.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.util + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class ReferenceCounted(val obj: T, var refCount: Int) + +internal abstract class ReferenceCountedSet { + + private val mutex = Mutex() + private val map = mutableMapOf>() + + suspend fun acquire(key: K): Entry { + val entry = + mutex.withLock { + map.getOrPut(key) { EntryImpl(this, key, valueForKey(key)) }.apply { refCount++ } + } + + if (entry.refCount == 1) { + onAllocate(entry) + } + + return entry + } + + suspend fun release(entry: Entry) { + require(entry is EntryImpl) { + "The given entry was expected to be an instance of ${EntryImpl::class.qualifiedName}, " + + "but was ${entry::class.qualifiedName}" + } + require(entry.set === this) { + "The given entry must be created by this object ($this), " + + "but was created by a different object (${entry.set})" + } + + val newRefCount = + mutex.withLock { + val entryFromMap = map[entry.key] + requireNotNull(entryFromMap) { "The given entry was not found in this set" } + require(entryFromMap === entry) { + "The key from the given entry was found in this set, but it was a different object" + } + require(entry.refCount > 0) { + "The refCount of the given entry was expected to be strictly greater than zero, " + + "but was ${entry.refCount}" + } + + entry.refCount-- + + if (entry.refCount == 0) { + map.remove(entry.key) + } + + entry.refCount + } + + if (newRefCount == 0) { + onFree(entry) + } + } + + protected abstract fun valueForKey(key: K): V + + protected open fun onAllocate(entry: Entry) {} + + protected open fun onFree(entry: Entry) {} + + interface Entry { + val key: K + val value: V + } + + private data class EntryImpl( + val set: ReferenceCountedSet, + override val key: K, + override val value: V, + var refCount: Int = 0, + ) : Entry + + companion object { + suspend fun ReferenceCountedSet.withAcquiredValue( + key: K, + callback: suspend (V) -> R + ): R { + val entry = acquire(key) + return try { + callback(entry.value) + } finally { + release(entry) + } + } + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SequencedReference.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SequencedReference.kt new file mode 100644 index 00000000000..94371b43e46 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SequencedReference.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.util + +import java.util.concurrent.atomic.AtomicLong + +internal data class SequencedReference(val sequenceNumber: Long, val ref: T) { + + companion object { + + private val nextSequenceId = AtomicLong(0) + + /** + * Returns a positive number on each invocation, with each returned value being strictly greater + * than any value previously returned in this process. + * + * This function is thread-safe and may be called concurrently by multiple threads and/or + * coroutines. + */ + fun nextSequenceNumber(): Long { + return nextSequenceId.incrementAndGet() + } + + fun SequencedReference.map(block: (T) -> U): SequencedReference = + SequencedReference(sequenceNumber, block(ref)) + + suspend fun SequencedReference.mapSuspending( + block: suspend (T) -> U + ): SequencedReference = SequencedReference(sequenceNumber, block(ref)) + + fun ?> U.newerOfThisAnd(other: U): U = + if (this == null && other == null) { + // Suppress the warning that `this` is guaranteed to be null because the `null` literal + // cannot + // be used in place of `this` because if this extension function is called on a non-nullable + // reference then `null` is a forbidden return value and compilation will fail. + @Suppress("KotlinConstantConditions") this + } else if (this == null) { + other + } else if (other == null) { + this + } else if (this.sequenceNumber > other.sequenceNumber) { + this + } else { + other + } + + inline fun SequencedReference.asTypeOrNull(): + SequencedReference? = + if (ref is U) { + @Suppress("UNCHECKED_CAST") + this as SequencedReference + } else { + null + } + + inline fun SequencedReference.asTypeOrThrow(): + SequencedReference = + asTypeOrNull() + ?: throw IllegalStateException( + "expected ref to have type ${U::class.qualifiedName}, " + + "but got ${ref::class.qualifiedName} ($ref)" + ) + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SuspendingLazy.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SuspendingLazy.kt new file mode 100644 index 00000000000..bac902f28ba --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SuspendingLazy.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.util + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * An adaptation of the standard library [lazy] builder that implements + * [LazyThreadSafetyMode.SYNCHRONIZED] with a suspending function and a [Mutex] rather than a + * blocking synchronization call. + * + * @param mutex the mutex to have locked when `initializer` is invoked; if null (the default) then a + * new lock will be used. + * @param coroutineContext the coroutine context with which to invoke `initializer`; if null (the + * default) then the context of the coroutine that calls [get] or [getLocked] will be used. + * @param initializer the block to invoke at most once to initialize this object's value. + */ +internal class SuspendingLazy( + mutex: Mutex? = null, + private val coroutineContext: CoroutineContext? = null, + initializer: suspend () -> T +) { + private val mutex = mutex ?: Mutex() + private var initializer: (suspend () -> T)? = initializer + @Volatile private var value: T? = null + + val initializedValueOrNull: T? + get() = value + + suspend inline fun get(): T = value ?: mutex.withLock { getLocked() } + + // This function _must_ be called by a coroutine that has locked the mutex given to the + // constructor; otherwise, a data race will occur, resulting in undefined behavior. + suspend fun getLocked(): T = + if (coroutineContext === null) { + getLockedInContext() + } else { + withContext(coroutineContext) { getLockedInContext() } + } + + private suspend inline fun getLockedInContext(): T = + value + ?: initializer!!().also { + value = it + initializer = null + } + + override fun toString(): String = + if (value !== null) value.toString() else "SuspendingLazy value not initialized yet." +} diff --git a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto new file mode 100644 index 00000000000..918227ef686 --- /dev/null +++ b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Adapted from http://google3/google/firebase/dataconnect/v1main/connector_service.proto;rcl=596717236 + +syntax = "proto3"; + +package google.firebase.dataconnect.v1beta; + +import "google/firebase/dataconnect/proto/graphql_error.proto"; +import "google/protobuf/struct.proto"; + +option java_package = "google.firebase.dataconnect.proto"; +option java_multiple_files = true; + +// Firebase Data Connect provides means to deploy a set of predefined GraphQL +// operations (queries and mutations) as a Connector. +// +// Firebase developers can build mobile and web apps that uses Connectors +// to access Data Sources directly. Connectors allow operations without +// admin credentials and help Firebase customers control the API exposure. +// +// Note: `ConnectorService` doesn't check IAM permissions and instead developers +// must define auth policies on each pre-defined operation to secure this +// connector. The auth policies typically define rules on the Firebase Auth +// token. +service ConnectorService { + // Execute a predefined query in a Connector. + rpc ExecuteQuery(ExecuteQueryRequest) returns (ExecuteQueryResponse) { + } + + // Execute a predefined mutation in a Connector. + rpc ExecuteMutation(ExecuteMutationRequest) returns (ExecuteMutationResponse) { + } +} + +// The ExecuteQuery request to Firebase Data Connect. +message ExecuteQueryRequest { + // The resource name of the connector to find the predefined query, in + // the format: + // ``` + // projects/{project}/locations/{location}/services/{service}/connectors/{connector} + // ``` + string name = 1; + + // The name of the GraphQL operation name. + // Required because all Connector operations must be named. + // See https://graphql.org/learn/queries/#operation-name. + // (-- api-linter: core::0122::name-suffix=disabled + // aip.dev/not-precedent: Must conform to GraphQL HTTP spec standard. --) + string operation_name = 2; + + // Values for GraphQL variables provided in this request. + google.protobuf.Struct variables = 3; +} + +// The ExecuteMutation request to Firebase Data Connect. +message ExecuteMutationRequest { + // The resource name of the connector to find the predefined mutation, in + // the format: + // ``` + // projects/{project}/locations/{location}/services/{service}/connectors/{connector} + // ``` + string name = 1; + + // The name of the GraphQL operation name. + // Required because all Connector operations must be named. + // See https://graphql.org/learn/queries/#operation-name. + // (-- api-linter: core::0122::name-suffix=disabled + // aip.dev/not-precedent: Must conform to GraphQL HTTP spec standard. --) + string operation_name = 2; + + // Values for GraphQL variables provided in this request. + google.protobuf.Struct variables = 3; +} + +// The ExecuteQuery response from Firebase Data Connect. +message ExecuteQueryResponse { + // The result of executing the requested operation. + google.protobuf.Struct data = 1; + // Errors of this response. + repeated GraphqlError errors = 2; +} + +// The ExecuteMutation response from Firebase Data Connect. +message ExecuteMutationResponse { + // The result of executing the requested operation. + google.protobuf.Struct data = 1; + // Errors of this response. + repeated GraphqlError errors = 2; +} diff --git a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/emulator_service.proto b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/emulator_service.proto new file mode 100644 index 00000000000..431e5106d90 --- /dev/null +++ b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/emulator_service.proto @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Adapted from http://google3/third_party/firebase/dataconnect/emulator/server/api/emulator/emulator_service.proto;l=86;rcl=642658022 + +// API protos for the dataconnect Emulator Service. + +syntax = "proto3"; + +package google.firebase.dataconnect.emulator; + +import "google/firebase/dataconnect/proto/graphql_error.proto"; + +option java_package = "google.firebase.dataconnect.proto"; +option java_multiple_files = true; + +service EmulatorService { + rpc GetEmulatorInfo(GetEmulatorInfoRequest) returns (EmulatorInfo) { + } + + rpc StreamEmulatorIssues(StreamEmulatorIssuesRequest) returns (stream EmulatorIssuesResponse) { + } +} + +message GetEmulatorInfoRequest {} + +message EmulatorInfo { + // The current version number of the emulator build. + string version = 1; + // The services that are currently running in the emulator. + repeated ServiceInfo services = 2; +} + +message ServiceInfo { + // The Firebase Data Connect Service ID in the resource name. + string service_id = 1; + // The Postgres connection string for the emulated service. + string connection_string = 2; +} + +message StreamEmulatorIssuesRequest { + // Optional query parameter. Default to the local service in dataconnect.yaml. + string service_id = 1; +} + +message EmulatorIssuesResponse { + repeated EmulatorIssue issues = 1; +} + +message EmulatorIssue { + enum Kind { + KIND_UNSPECIFIED = 0; + SQL_CONNECTION = 1; + SQL_MIGRATION = 2; + VERTEX_AI = 3; + } + Kind kind = 1; + enum Severity { + SEVERITY_UNSPECIFIED = 0; + DEBUG = 1; + NOTICE = 2; + ALERT = 3; + } + Severity severity = 2; + string message = 3; +} \ No newline at end of file diff --git a/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto new file mode 100644 index 00000000000..f2ca45e9f66 --- /dev/null +++ b/firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto @@ -0,0 +1,85 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Adapted from http://google3/google/firebase/dataconnect/v1main/graphql_error.proto;rcl=597595444 + +syntax = "proto3"; + +package google.firebase.dataconnect.v1beta; + +import "google/protobuf/struct.proto"; + +option java_package = "google.firebase.dataconnect.proto"; +option java_multiple_files = true; + +// GraphqlError conforms to the GraphQL error spec. +// https://spec.graphql.org/draft/#sec-Errors +// +// Firebase Data Connect API surfaces `GraphqlError` in various APIs: +// - Upon compile error, `UpdateSchema` and `UpdateConnector` return +// Code.Invalid_Argument with a list of `GraphqlError` in error details. +// - Upon query compile error, `ExecuteGraphql` and `ExecuteGraphqlRead` return +// Code.OK with a list of `GraphqlError` in response body. +// - Upon query execution error, `ExecuteGraphql`, `ExecuteGraphqlRead`, +// `ExecuteMutation` and `ExecuteQuery` all return Code.OK with a list of +// `GraphqlError` in response body. +message GraphqlError { + // The detailed error message. + // The message should help developer understand the underlying problem without + // leaking internal data. + string message = 1; + + // The source locations where the error occurred. + // Locations should help developers and toolings identify the source of error + // quickly. + // + // Included in admin endpoints (`ExecuteGraphql`, `ExecuteGraphqlRead`, + // `UpdateSchema` and `UpdateConnector`) to reference the provided GraphQL + // GQL document. + // + // Omitted in `ExecuteMutation` and `ExecuteQuery` since the caller shouldn't + // have access access the underlying GQL source. + repeated SourceLocation locations = 2; + + // The result field which could not be populated due to error. + // + // Clients can use path to identify whether a null result is intentional or + // caused by a runtime error. + // It should be a list of string or index from the root of GraphQL query + // document. + google.protobuf.ListValue path = 3; + + // Additional error information. + GraphqlErrorExtensions extensions = 4; +} + +// SourceLocation references a location in a GraphQL source. +message SourceLocation { + // Line number starting at 1. + int32 line = 1; + // Column number starting at 1. + int32 column = 2; +} + +// GraphqlErrorExtensions contains additional information of `GraphqlError`. +// (-- TODO(b/305311379): include more detailed error fields: +// go/firemat:api:gql-errors. --) +message GraphqlErrorExtensions { + // The source file name where the error occurred. + // Included only for `UpdateSchema` and `UpdateConnector`, it corresponds + // to `File.path` of the provided `Source`. + string file = 1; +} diff --git a/firebase-dataconnect/src/test/AndroidManifest.xml b/firebase-dataconnect/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..151c7fb817a --- /dev/null +++ b/firebase-dataconnect/src/test/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueSerializerUnitTest.kt new file mode 100644 index 00000000000..4914a1db09b --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueSerializerUnitTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.StructureKind +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +class AnyValueSerializerUnitTest { + + @Test + fun `descriptor should have expected values`() { + assertSoftly { + AnyValueSerializer.descriptor.serialName shouldBe "com.google.firebase.dataconnect.AnyValue" + AnyValueSerializer.descriptor.kind shouldBe StructureKind.CLASS + } + } + + @Test + fun `serialize() should throw UnsupportedOperationException`() { + shouldThrow { AnyValueSerializer.serialize(mockk(), mockk()) } + } + + @Test + fun `deserialize() should throw UnsupportedOperationException`() { + shouldThrow { AnyValueSerializer.deserialize(mockk()) } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueUnitTest.kt new file mode 100644 index 00000000000..840090c9765 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueUnitTest.kt @@ -0,0 +1,603 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.serializers.AnyValueSerializer +import com.google.firebase.dataconnect.testutil.DataConnectAnySerializer +import com.google.firebase.dataconnect.testutil.EdgeCases +import com.google.firebase.dataconnect.testutil.anyListScalar +import com.google.firebase.dataconnect.testutil.anyMapScalar +import com.google.firebase.dataconnect.testutil.anyNumberScalar +import com.google.firebase.dataconnect.testutil.anyScalar +import com.google.firebase.dataconnect.testutil.anyStringScalar +import com.google.firebase.dataconnect.testutil.filterNotNull +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToValue +import io.kotest.assertions.assertSoftly +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.map +import io.kotest.property.checkAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +@OptIn(ExperimentalKotest::class) +class AnyValueUnitTest { + + @Test + fun `default serializer should be AnyValueSerializer`() { + serializer() shouldBeSameInstanceAs AnyValueSerializer + } + + @Test + fun `constructor(String) creates an object with the expected value (edge cases)`() = runTest { + for (value in EdgeCases.strings) { + AnyValue(value).value shouldBe value + } + } + + @Test + fun `constructor(String) creates an object with the expected value (normal cases)`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyStringScalar()) { AnyValue(it).value shouldBe it } + } + + @Test + fun `constructor(Double) creates an object with the expected value (edge cases)`() = runTest { + for (value in EdgeCases.numbers) { + AnyValue(value).value shouldBe value + } + } + + @Test + fun `constructor(Double) creates an object with the expected value (normal cases)`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyNumberScalar()) { AnyValue(it).value shouldBe it } + } + + @Test + fun `constructor(Boolean) creates an object with the expected value`() { + assertSoftly { + AnyValue(true).value shouldBe true + AnyValue(false).value shouldBe false + } + } + + @Test + fun `constructor(List) creates an object with the expected value (edge cases)`() { + assertSoftly { + for (value in EdgeCases.lists) { + AnyValue(value).value shouldBe value + } + } + } + + @Test + fun `constructor(List) creates an object with the expected value (normal cases)`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyListScalar()) { AnyValue(it).value shouldBe it } + } + + @Test + fun `constructor(Map) creates an object with the expected value (edge cases)`() { + assertSoftly { + for (value in EdgeCases.maps) { + AnyValue(value).value shouldBe value + } + } + } + + @Test + fun `constructor(Map) creates an object with the expected value (normal cases)`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyMapScalar()) { AnyValue(it).value shouldBe it } + } + + @Test + fun `decode() can decode strings (edge cases)`() { + val serializer = serializer() + assertSoftly { + for (value in EdgeCases.strings) { + AnyValue(value).decode(serializer) shouldBe value + } + } + } + + @Test + fun `decode() can decode strings (normal cases)`() = runTest { + val serializer = serializer() + checkAll(normalCasePropTestConfig, Arb.anyStringScalar()) { + AnyValue(it).decode(serializer) shouldBe it + } + } + + @Test + fun `decode() can decode doubles (edge cases)`() { + val serializer = serializer() + assertSoftly { + for (value in EdgeCases.numbers) { + AnyValue(value).decode(serializer) shouldBe value + } + } + } + + @Test + fun `decode() can decode doubles (normal cases)`() = runTest { + val serializer = serializer() + checkAll(normalCasePropTestConfig, Arb.anyNumberScalar()) { + AnyValue(it).decode(serializer) shouldBe it + } + } + + @Test + fun `decode() can decode booleans (edge cases)`() { + val serializer = serializer() + assertSoftly { + AnyValue(true).decode(serializer) shouldBe true + AnyValue(false).decode(serializer) shouldBe false + } + } + + @Test + fun `decode() can decode lists (edge cases)`() { + val serializer = ListSerializer(DataConnectAnySerializer) + assertSoftly { + for (value in EdgeCases.lists) { + AnyValue(value).decode(serializer) shouldContainExactly value + } + } + } + + @Test + fun `decode() can decode lists (normal cases)`() = runTest { + val serializer = ListSerializer(DataConnectAnySerializer) + checkAll(normalCasePropTestConfig, Arb.anyListScalar()) { + AnyValue(it).decode(serializer) shouldContainExactly it + } + } + + @Test + fun `decode() can decode maps (edge cases)`() { + val serializer = MapSerializer(serializer(), DataConnectAnySerializer) + assertSoftly { + for (value in EdgeCases.maps) { + AnyValue(value).decode(serializer) shouldBe value + } + } + } + + @Test + fun `decode() can decode maps (normal cases)`() = runTest { + val serializer = MapSerializer(serializer(), DataConnectAnySerializer) + checkAll(normalCasePropTestConfig, Arb.anyMapScalar()) { + AnyValue(it).decode(serializer) shouldBe it + } + } + + @Test + fun `decode() can decode nested AnyValue`() { + @Serializable data class TestData(val a: Int, val b: AnyValue) + val nestedAnyValueList = listOf("a", 12.34, mapOf("foo" to "bar")) + val anyValue = AnyValue(mapOf("a" to 12.0, "b" to nestedAnyValueList)) + + anyValue.decode(serializer()) shouldBe + TestData(a = 12, b = AnyValue(nestedAnyValueList)) + } + + @Test + fun `decode() can decode @Serializable class with non-nullable scalar properties`() { + @Serializable + data class TestData( + val int: Int, + val string: String, + val boolean: Boolean, + val double: Double, + ) + val testData = TestData(42, "w82qq4jbb6", true, 12.34) + val anyValue = AnyValue(encodeToValue(testData)) + + anyValue.decode(serializer()) shouldBe testData + } + + @Test + fun `decode() can decode @Serializable class with nullable scalar properties with non-null values`() { + @Serializable + data class TestData( + val int: Int?, + val string: String?, + val boolean: Boolean?, + val double: Double?, + ) + val testData = TestData(42, "srg7yzecwq", true, 12.34) + val anyValue = AnyValue(encodeToValue(testData)) + + anyValue.decode(serializer()) shouldBe testData + } + + @Test + fun `decode() can decode @Serializable class with nullable scalar properties with null values`() { + @Serializable + data class TestData( + val int: Int?, + val string: String?, + val boolean: Boolean?, + val double: Double?, + ) + val testData = TestData(null, null, null, null) + val anyValue = AnyValue(encodeToValue(testData)) + + anyValue.decode(serializer()) shouldBe testData + } + + @Test + fun `decode() can decode @Serializable class with nested values`() { + @Serializable data class Foo(val int: Int, val foo: Foo? = null) + @Serializable data class TestData(val list: List, val foo: Foo) + val testData = TestData(listOf(Foo(111), Foo(222, Foo(333))), Foo(444, Foo(555))) + val anyValue = AnyValue(encodeToValue(testData)) + + anyValue.decode(serializer()) shouldBe testData + } + + @Test + fun `decode() passes along the serialization module`() { + val capturedSerializerModule = MutableStateFlow(null) + val stringSerializer = serializer() + val serializer = + object : DeserializationStrategy by stringSerializer { + override fun deserialize(decoder: Decoder): String { + capturedSerializerModule.value = decoder.serializersModule + return stringSerializer.deserialize(decoder) + } + } + val serializerModule = SerializersModule {} + val anyValue = AnyValue("yqvjgabk2e") + + anyValue.decode(serializer, serializerModule) + + capturedSerializerModule.value shouldBeSameInstanceAs serializerModule + } + + @Test + fun `decode() uses the default serializer if not explicitly specified`() { + val anyValue = AnyValue("mb6jq8jabp") + anyValue.decode() shouldBe "mb6jq8jabp" + } + + @Test + fun `equals(this) returns true`() { + val anyValue = AnyValue(42.0) + anyValue.equals(anyValue).shouldBeTrue() + } + + @Test + fun `equals(equal, but distinct, instance) returns true`() = runTest { + checkAll(iterations = 1000, Arb.anyScalar().filterNotNull()) { + val anyValue1 = AnyValue.fromAny(it) + val anyValue2 = AnyValue.fromAny(it) + anyValue1.equals(anyValue2).shouldBeTrue() + } + } + + @Test + fun `equals(null) returns false`() { + val anyValue = AnyValue(42.0) + anyValue.equals(null).shouldBeFalse() + } + + @Test + fun `equals(some other type) returns false`() { + val anyValue = AnyValue(42.0) + anyValue.equals("not an AnyValue object").shouldBeFalse() + } + + @Test + fun `equals(unequal instance) returns false`() = runTest { + val values = Arb.anyScalar().filterNotNull() + checkAll(iterations = 1000, values) { value -> + val anyValue1 = AnyValue.fromAny(value) + val anyValue2 = AnyValue.fromAny(values.filterNot { it == value }.bind()) + anyValue1.equals(anyValue2).shouldBeFalse() + } + } + + @Test + fun `hashCode() should return the same value when invoked repeatedly`() = runTest { + val values = Arb.anyScalar().filterNotNull().map(AnyValue::fromAny) + checkAll(iterations = 1000, values) { anyValue -> + val hashCode = anyValue.hashCode() + val hashCodes = List(100) { anyValue.hashCode() }.toSet() + hashCodes.shouldContainExactly(hashCode) + } + } + + @Test + fun `hashCode() should return different value when the encapsulated value has a different hash code`() = + runTest { + val values = Arb.anyScalar().filterNotNull() + checkAll(normalCasePropTestConfig, values) { value1 -> + val value2 = values.bind() + val anyValue1 = AnyValue.fromAny(value1) + val anyValue2 = AnyValue.fromAny(value2) + if (value1.hashCode() == value2.hashCode()) { + anyValue1.hashCode() shouldBe anyValue2.hashCode() + } else { + anyValue1.hashCode() shouldNotBe anyValue2.hashCode() + } + } + } + + @Test + fun `toString() should not throw`() = runTest { + val values = Arb.anyScalar().filterNotNull().map(AnyValue::fromAny) + checkAll(normalCasePropTestConfig, values) { it.toString() } + } + + @Test + fun `encode() can encode strings (edge cases)`() { + val serializer = serializer() + assertSoftly { + for (value in EdgeCases.strings) { + AnyValue.encode(value, serializer) shouldBe AnyValue(value) + } + } + } + + @Test + fun `encode() can encode strings (normal cases)`() = runTest { + val serializer = serializer() + checkAll(normalCasePropTestConfig, Arb.anyStringScalar()) { + AnyValue.encode(it, serializer) shouldBe AnyValue(it) + } + } + + @Test + fun `encode() can encode doubles (edge cases)`() { + val serializer = serializer() + assertSoftly { + for (value in EdgeCases.numbers) { + AnyValue.encode(value, serializer) shouldBe AnyValue(value) + } + } + } + + @Test + fun `encode() can encode doubles (normal cases)`() = runTest { + val serializer = serializer() + checkAll(normalCasePropTestConfig, Arb.anyNumberScalar()) { + AnyValue.encode(it, serializer) shouldBe AnyValue(it) + } + } + + @Test + fun `encode() can encode booleans (edge cases)`() { + val serializer = serializer() + assertSoftly { + AnyValue.encode(true, serializer) shouldBe AnyValue(true) + AnyValue.encode(false, serializer) shouldBe AnyValue(false) + } + } + + @Test + fun `encode() can encode lists (edge cases)`() { + val serializer = ListSerializer(DataConnectAnySerializer) + assertSoftly { + for (value in EdgeCases.lists) { + AnyValue.encode(value, serializer) shouldBe AnyValue(value) + } + } + } + + @Test + fun `encode() can encode lists (normal cases)`() = runTest { + val serializer = ListSerializer(DataConnectAnySerializer) + checkAll(normalCasePropTestConfig, Arb.anyListScalar()) { + AnyValue.encode(it, serializer) shouldBe AnyValue(it) + } + } + + @Test + fun `encode() can encode maps (edge cases)`() { + val serializer = MapSerializer(serializer(), DataConnectAnySerializer) + assertSoftly { + for (value in EdgeCases.maps) { + AnyValue.encode(value, serializer) shouldBe AnyValue(value) + } + } + } + + @Test + fun `encode() can encode maps (normal cases)`() = runTest { + val serializer = MapSerializer(serializer(), DataConnectAnySerializer) + checkAll(normalCasePropTestConfig, Arb.anyMapScalar()) { + AnyValue.encode(it, serializer) shouldBe AnyValue(it) + } + } + + @Test + fun `encode() can encode nested AnyValue`() { + @Serializable data class TestData(val a: Int, val b: AnyValue) + val nestedAnyValueList = listOf("a", 12.34, mapOf("foo" to "bar")) + val testData = TestData(a = 12, b = AnyValue(nestedAnyValueList)) + + val anyValue = AnyValue.encode(testData) + + anyValue.value shouldBe mapOf("a" to 12.0, "b" to nestedAnyValueList) + } + + @Test + fun `encode() can encode @Serializable class with non-nullable scalar properties`() { + @Serializable + data class TestData( + val int: Int, + val string: String, + val boolean: Boolean, + val double: Double, + ) + val testData = TestData(42, "gkg3jsp2jz", true, 12.34) + + val anyValue = AnyValue.encode(testData) + + anyValue.value shouldBe + (mapOf("int" to 42.0, "string" to "gkg3jsp2jz", "boolean" to true, "double" to 12.34)) + } + + @Test + fun `encode() can encode @Serializable class with nullable scalar properties with non-null values`() { + @Serializable + data class TestData( + val int: Int?, + val string: String?, + val boolean: Boolean?, + val double: Double?, + ) + val testData = TestData(42, "mj64xgc2sz", true, 12.34) + + val anyValue = AnyValue.encode(testData) + + anyValue.value shouldBe + (mapOf("int" to 42.0, "string" to "mj64xgc2sz", "boolean" to true, "double" to 12.34)) + } + + @Test + fun `encode() can encode @Serializable class with nullable scalar properties with null values`() { + @Serializable + data class TestData( + val int: Int?, + val string: String?, + val boolean: Boolean?, + val double: Double?, + ) + val testData = TestData(null, null, null, null) + + val anyValue = AnyValue.encode(testData) + + anyValue.value shouldBe + (mapOf("int" to null, "string" to null, "boolean" to null, "double" to null)) + } + + @Test + fun `encode() can encode @Serializable class with nested values`() { + @Serializable data class Foo(val int: Int, val foo: Foo? = null) + @Serializable data class TestData(val list: List, val foo: Foo) + val testData = TestData(listOf(Foo(111), Foo(222, Foo(333))), Foo(444, Foo(555))) + + val anyValue = AnyValue.encode(testData) + + anyValue.value shouldBe + (mapOf( + "list" to + listOf( + mapOf("int" to 111.0, "foo" to null), + mapOf("int" to 222.0, "foo" to mapOf("int" to 333.0, "foo" to null)) + ), + "foo" to mapOf("int" to 444.0, "foo" to mapOf("int" to 555.0, "foo" to null)) + )) + } + + @Test + fun `encode() passes along the serialization module`() { + val capturedSerializerModule = MutableStateFlow(null) + val stringSerializer = serializer() + val serializer = + object : SerializationStrategy by stringSerializer { + override fun serialize(encoder: Encoder, value: String) { + capturedSerializerModule.value = encoder.serializersModule + return stringSerializer.serialize(encoder, value) + } + } + val serializerModule = SerializersModule {} + + AnyValue.encode("jn7wve4qwt", serializer, serializerModule) + + capturedSerializerModule.value shouldBeSameInstanceAs serializerModule + } + + @Test + fun `encode() uses the default serializer if not explicitly specified`() { + AnyValue.encode("we47rcjzm4") shouldBe AnyValue("we47rcjzm4") + } + + @Test + fun `fromNullableAny() edge cases`() { + for (value in EdgeCases.anyScalars) { + val anyValue = AnyValue.fromAny(value) + if (value === null) { + anyValue.shouldBeNull() + } else { + anyValue.shouldNotBeNull() + anyValue.value shouldBe value + } + } + } + + @Test + fun `fromNullableAny() normal cases`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyScalar()) { value -> + val anyValue = AnyValue.fromAny(value) + if (value === null) { + anyValue.shouldBeNull() + } else { + anyValue.shouldNotBeNull() + anyValue.value shouldBe value + } + } + } + + @Test + fun `fromNonNullableAny() edge cases`() { + for (value in EdgeCases.anyScalars.filterNotNull()) { + val anyValue = AnyValue.fromAny(value) + anyValue.value shouldBe value + } + } + + @Test + fun `fromNonNullableAny() normal cases`() = runTest { + checkAll(normalCasePropTestConfig, Arb.anyScalar().filterNotNull()) { value -> + val anyValue = AnyValue.fromAny(value) + anyValue.shouldNotBeNull() + anyValue.value shouldBe value + } + } + + private companion object { + + val normalCasePropTestConfig = + PropTestConfig( + iterations = 1000, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.0) + ) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ConnectorConfigUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ConnectorConfigUnitTest.kt new file mode 100644 index 00000000000..23d3f4fcdbb --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ConnectorConfigUnitTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import org.junit.Test + +class ConnectorConfigUnitTest { + + private val sampleConfig = + ConnectorConfig( + connector = SAMPLE_CONNECTOR, + location = SAMPLE_LOCATION, + serviceId = SAMPLE_SERVICE_ID + ) + + @Test + fun `'connector' property should be the same object given to the constructor`() { + val connector = "Test Connector" + val config = + ConnectorConfig( + connector = connector, + location = SAMPLE_LOCATION, + serviceId = SAMPLE_SERVICE_ID, + ) + + assertThat(config.connector).isSameInstanceAs(connector) + } + + @Test + fun `'location' property should be the same object given to the constructor`() { + val location = "Test Location" + val config = + ConnectorConfig( + connector = SAMPLE_CONNECTOR, + location = location, + serviceId = SAMPLE_SERVICE_ID, + ) + + assertThat(config.location).isSameInstanceAs(location) + } + + @Test + fun `'serviceId' property should be the same object given to the constructor`() { + val serviceId = "Test Service Id" + val config = + ConnectorConfig( + connector = SAMPLE_CONNECTOR, + location = SAMPLE_LOCATION, + serviceId = serviceId, + ) + assertThat(config.serviceId).isSameInstanceAs(serviceId) + } + + @Test + fun `toString() returns a string that incorporates all property values`() { + val config = + ConnectorConfig(connector = "MyConnector", location = "MyLocation", serviceId = "MyServiceId") + + val toStringResult = config.toString() + + assertThat(toStringResult).startsWith("ConnectorConfig(") + assertThat(toStringResult).endsWith(")") + assertThat(toStringResult).containsWithNonAdjacentText("serviceId=MyServiceId") + assertThat(toStringResult).containsWithNonAdjacentText("location=MyLocation") + assertThat(toStringResult).containsWithNonAdjacentText("connector=MyConnector") + } + + @Test + fun `equals() should return true for the exact same instance`() { + val config = sampleConfig + + assertThat(config.equals(config)).isTrue() + } + + @Test + fun `equals() should return true for an equal instance`() { + val config = sampleConfig + val configCopy = config.copy() + + assertThat(config.equals(configCopy)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + assertThat(sampleConfig.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + assertThat(sampleConfig.equals("Not A ConnectorConfig Instance")).isFalse() + } + + @Test + fun `equals() should return false when only 'connector' differs`() { + val config1 = sampleConfig.copy(connector = "Connector1") + val config2 = sampleConfig.copy(connector = "Connector2") + + assertThat(config1.equals(config2)).isFalse() + } + + @Test + fun `equals() should return false when only 'location' differs`() { + val config1 = sampleConfig.copy(location = "Location1") + val config2 = sampleConfig.copy(location = "Location2") + + assertThat(config1.equals(config2)).isFalse() + } + + @Test + fun `equals() should return false when only 'serviceId' differs`() { + val config1 = sampleConfig.copy(serviceId = "ServiceId1") + val config2 = sampleConfig.copy(serviceId = "ServiceId2") + + assertThat(config1.equals(config2)).isFalse() + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() { + val hashCode = sampleConfig.hashCode() + + assertThat(sampleConfig.hashCode()).isEqualTo(hashCode) + assertThat(sampleConfig.hashCode()).isEqualTo(hashCode) + assertThat(sampleConfig.hashCode()).isEqualTo(hashCode) + } + + @Test + fun `hashCode() should return the same value on equal objects`() { + val config = sampleConfig + val configCopy = config.copy() + + assertThat(config.hashCode()).isEqualTo(configCopy.hashCode()) + } + + @Test + fun `hashCode() should return a different value when only 'connector' differs`() { + val config1 = sampleConfig.copy(connector = "Connector1") + val config2 = sampleConfig.copy(connector = "Connector2") + + assertThat(config1.hashCode()).isNotEqualTo(config2.hashCode()) + } + + @Test + fun `hashCode() should return a different value when only 'location' differs`() { + val config1 = sampleConfig.copy(location = "Location1") + val config2 = sampleConfig.copy(location = "Location2") + + assertThat(config1.hashCode()).isNotEqualTo(config2.hashCode()) + } + + @Test + fun `hashCode() should return a different value when only 'serviceId' differs`() { + val config1 = sampleConfig.copy(serviceId = "ServiceId1") + val config2 = sampleConfig.copy(serviceId = "ServiceId2") + + assertThat(config1.hashCode()).isNotEqualTo(config2.hashCode()) + } + + @Test + fun `copy() should return a new, equal object when invoked with no explicit arguments`() { + val config2 = sampleConfig.copy() + + assertThat(config2).isNotSameInstanceAs(sampleConfig) + assertThat(config2.connector).isSameInstanceAs(sampleConfig.connector) + assertThat(config2.location).isSameInstanceAs(sampleConfig.location) + assertThat(config2.serviceId).isSameInstanceAs(sampleConfig.serviceId) + } + + @Test + fun `copy() should return an object with the given 'connector'`() { + val newConnector = sampleConfig.connector + "ZZZZ" + + val config2 = sampleConfig.copy(connector = newConnector) + + assertThat(config2.connector).isSameInstanceAs(newConnector) + assertThat(config2.location).isSameInstanceAs(sampleConfig.location) + assertThat(config2.serviceId).isSameInstanceAs(sampleConfig.serviceId) + } + + @Test + fun `copy() should return an object with the given 'location'`() { + val newLocation = sampleConfig.location + "ZZZZ" + + val config2 = sampleConfig.copy(location = newLocation) + + assertThat(config2.connector).isSameInstanceAs(sampleConfig.connector) + assertThat(config2.location).isSameInstanceAs(newLocation) + assertThat(config2.serviceId).isSameInstanceAs(sampleConfig.serviceId) + } + + @Test + fun `copy() should return an object with the given 'serviceId'`() { + val newServiceId = sampleConfig.serviceId + "ZZZZ" + + val config2 = sampleConfig.copy(serviceId = newServiceId) + + assertThat(config2.connector).isSameInstanceAs(sampleConfig.connector) + assertThat(config2.location).isSameInstanceAs(sampleConfig.location) + assertThat(config2.serviceId).isSameInstanceAs(newServiceId) + } + + @Test + fun `copy() should return an object with properties set to all given arguments`() { + val newConnector = sampleConfig.connector + "ZZZZ" + val newLocation = sampleConfig.location + "ZZZZ" + val newServiceId = sampleConfig.serviceId + "ZZZZ" + + val config2 = + sampleConfig.copy(connector = newConnector, location = newLocation, serviceId = newServiceId) + + assertThat(config2.connector).isSameInstanceAs(newConnector) + assertThat(config2.location).isSameInstanceAs(newLocation) + assertThat(config2.serviceId).isSameInstanceAs(newServiceId) + } + + companion object { + const val SAMPLE_CONNECTOR = "SampleConnector" + const val SAMPLE_LOCATION = "SampleLocation" + const val SAMPLE_SERVICE_ID = "SampleServiceId" + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt new file mode 100644 index 00000000000..96ce0119eae --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt @@ -0,0 +1,296 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.DataConnectError.PathSegment +import com.google.firebase.dataconnect.DataConnectError.SourceLocation +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import org.junit.Test + +class DataConnectErrorUnitTest { + + @Test + fun `message should be the same object given to the constructor`() { + val message = "This is the test message" + val dataConnectError = + DataConnectError(message = message, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.message).isSameInstanceAs(message) + } + + @Test + fun `path should be the same object given to the constructor`() { + val path = listOf(PathSegment.Field("foo"), PathSegment.ListIndex(42)) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = path, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.path).isSameInstanceAs(path) + } + + @Test + fun `locations should be the same object given to the constructor`() { + val locations = + listOf(SourceLocation(line = 0, column = -1), SourceLocation(line = 5, column = 6)) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = locations) + assertThat(dataConnectError.locations).isSameInstanceAs(locations) + } + + @Test + fun `toString() should incorporate the message`() { + val message = "This is the test message" + val dataConnectError = + DataConnectError(message = message, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.toString()).containsWithNonAdjacentText(message) + } + + @Test + fun `toString() should incorporate the fields from the path separated by dots`() { + val path = listOf(PathSegment.Field("foo"), PathSegment.Field("bar"), PathSegment.Field("baz")) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = path, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.toString()).containsWithNonAdjacentText("foo.bar.baz") + } + + @Test + fun `toString() should incorporate the list indexes from the path surround by square brackets`() { + val path = + listOf(PathSegment.ListIndex(42), PathSegment.ListIndex(99), PathSegment.ListIndex(1)) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = path, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.toString()).containsWithNonAdjacentText("[42][99][1]") + } + + @Test + fun `toString() should incorporate the fields and list indexes from the path`() { + val path = + listOf( + PathSegment.Field("foo"), + PathSegment.ListIndex(99), + PathSegment.Field("bar"), + PathSegment.ListIndex(22), + PathSegment.ListIndex(33) + ) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = path, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.toString()).containsWithNonAdjacentText("foo[99].bar[22][33]") + } + + @Test + fun `toString() should incorporate the locations`() { + val locations = + listOf(SourceLocation(line = 1, column = 2), SourceLocation(line = -1, column = -2)) + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = locations) + assertThat(dataConnectError.toString()).containsWithNonAdjacentText("1:2") + assertThat(dataConnectError.toString()).containsWithNonAdjacentText("-1:-2") + } + + @Test + fun `equals() should return true for the exact same instance`() { + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.equals(dataConnectError)).isTrue() + } + + @Test + fun `equals() should return true for an equal instance`() { + val dataConnectError1 = + DataConnectError( + message = "Test Message", + path = listOf(PathSegment.Field("foo"), PathSegment.ListIndex(42)), + locations = + listOf(SourceLocation(line = 36, column = 32), SourceLocation(line = 4, column = 5)) + ) + val dataConnectError2 = + DataConnectError( + message = "Test Message", + path = listOf(PathSegment.Field("foo"), PathSegment.ListIndex(42)), + locations = + listOf(SourceLocation(line = 36, column = 32), SourceLocation(line = 4, column = 5)) + ) + assertThat(dataConnectError1.equals(dataConnectError2)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError.equals(listOf("foo"))).isFalse() + } + + @Test + fun `equals() should return false when only message differs`() { + val dataConnectError1 = + DataConnectError(message = "Test Message1", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + val dataConnectError2 = + DataConnectError(message = "Test Message2", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `equals() should return false when message differs only in character case`() { + val dataConnectError1 = + DataConnectError(message = "A", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + val dataConnectError2 = + DataConnectError(message = "a", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `equals() should return false when path differs, with field`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.Field("a")), + locations = SAMPLE_LOCATIONS + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.Field("z")), + locations = SAMPLE_LOCATIONS + ) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `equals() should return false when path differs, with list index`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.ListIndex(1)), + locations = SAMPLE_LOCATIONS + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.ListIndex(2)), + locations = SAMPLE_LOCATIONS + ) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `equals() should return false when path differs, with field and list index`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.ListIndex(1)), + locations = SAMPLE_LOCATIONS + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.Field("foo")), + locations = SAMPLE_LOCATIONS + ) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `equals() should return false when locations differ`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = SAMPLE_PATH, + locations = listOf(SourceLocation(line = 36, column = 32)) + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = SAMPLE_PATH, + locations = listOf(SourceLocation(line = 32, column = 36)) + ) + assertThat(dataConnectError1.equals(dataConnectError2)).isFalse() + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() { + val dataConnectError = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + val hashCode = dataConnectError.hashCode() + assertThat(dataConnectError.hashCode()).isEqualTo(hashCode) + assertThat(dataConnectError.hashCode()).isEqualTo(hashCode) + assertThat(dataConnectError.hashCode()).isEqualTo(hashCode) + } + + @Test + fun `hashCode() should return the same value on equal objects`() { + val dataConnectError1 = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + val dataConnectError2 = + DataConnectError(message = SAMPLE_MESSAGE, path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError1.hashCode()).isEqualTo(dataConnectError2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if message is different`() { + val dataConnectError1 = + DataConnectError(message = "Test Message 1", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + val dataConnectError2 = + DataConnectError(message = "Test Message 2", path = SAMPLE_PATH, locations = SAMPLE_LOCATIONS) + assertThat(dataConnectError1.hashCode()).isNotEqualTo(dataConnectError2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if path is different`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.Field("foo")), + locations = SAMPLE_LOCATIONS + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = listOf(PathSegment.ListIndex(42)), + locations = SAMPLE_LOCATIONS + ) + assertThat(dataConnectError1.hashCode()).isNotEqualTo(dataConnectError2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if locations is different`() { + val dataConnectError1 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = SAMPLE_PATH, + locations = listOf(SourceLocation(line = 81, column = 18)) + ) + val dataConnectError2 = + DataConnectError( + message = SAMPLE_MESSAGE, + path = SAMPLE_PATH, + locations = listOf(SourceLocation(line = 18, column = 81)) + ) + assertThat(dataConnectError1.hashCode()).isNotEqualTo(dataConnectError2.hashCode()) + } + + private companion object { + val SAMPLE_MESSAGE = "This is a sample message" + val SAMPLE_PATH = listOf(PathSegment.Field("foo"), PathSegment.ListIndex(42)) + val SAMPLE_LOCATIONS = + listOf(SourceLocation(line = 42, column = 24), SourceLocation(line = 91, column = 19)) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt new file mode 100644 index 00000000000..faee78f3ea5 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import org.junit.Test + +class DataConnectSettingsUnitTest { + + @Test + fun `default constructor arguments are correct`() { + val settings = DataConnectSettings() + + assertThat(settings.host).isEqualTo("firebasedataconnect.googleapis.com") + assertThat(settings.sslEnabled).isTrue() + } + + @Test + fun `'host' property should be the same object given to the constructor`() { + val host = "Test Host" + + val settings = DataConnectSettings(host = host) + + assertThat(settings.host).isSameInstanceAs(host) + } + + @Test + fun `'sslEnabled' property should be the same object given to the constructor`() { + val settingsWithSslEnabledTrue = DataConnectSettings(sslEnabled = true) + val settingsWithSslEnabledFalse = DataConnectSettings(sslEnabled = false) + + assertThat(settingsWithSslEnabledTrue.sslEnabled).isTrue() + assertThat(settingsWithSslEnabledFalse.sslEnabled).isFalse() + } + + @Test + fun `toString() returns a string that incorporates all property values`() { + val settings = DataConnectSettings(host = "MyHost", sslEnabled = false) + + val toStringResult = settings.toString() + + assertThat(toStringResult).startsWith("DataConnectSettings(") + assertThat(toStringResult).endsWith(")") + assertThat(toStringResult).containsWithNonAdjacentText("host=MyHost") + assertThat(toStringResult).containsWithNonAdjacentText("sslEnabled=false") + } + + @Test + fun `equals() should return true for the exact same instance`() { + val settings = DataConnectSettings() + + assertThat(settings.equals(settings)).isTrue() + } + + @Test + fun `equals() should return true for an equal instance`() { + val settings = DataConnectSettings() + val settingsCopy = settings.copy() + + assertThat(settings.equals(settingsCopy)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val settings = DataConnectSettings() + + assertThat(settings.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val settings = DataConnectSettings() + + assertThat(settings.equals("Not A DataConnectSettings Instance")).isFalse() + } + + @Test + fun `equals() should return false when only 'host' differs`() { + val settings1 = DataConnectSettings(host = "Host1") + val settings2 = DataConnectSettings(host = "Host2") + + assertThat(settings1.equals(settings2)).isFalse() + } + + @Test + fun `equals() should return false when only 'sslEnabled' differs`() { + val settings1 = DataConnectSettings(sslEnabled = true) + val settings2 = DataConnectSettings(sslEnabled = false) + + assertThat(settings1.equals(settings2)).isFalse() + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() { + val settings = DataConnectSettings() + + val hashCode = settings.hashCode() + + assertThat(settings.hashCode()).isEqualTo(hashCode) + assertThat(settings.hashCode()).isEqualTo(hashCode) + assertThat(settings.hashCode()).isEqualTo(hashCode) + } + + @Test + fun `hashCode() should return the same value on equal objects`() { + val settings = DataConnectSettings() + val settingsCopy = settings.copy() + + assertThat(settings.hashCode()).isEqualTo(settingsCopy.hashCode()) + } + + @Test + fun `hashCode() should return a different value when only 'host' differs`() { + val settings1 = DataConnectSettings(host = "Host1") + val settings2 = DataConnectSettings(host = "Host2") + + assertThat(settings1.hashCode()).isNotEqualTo(settings2.hashCode()) + } + + @Test + fun `hashCode() should return a different value when only 'sslEnabled' differs`() { + val settings1 = DataConnectSettings(sslEnabled = true) + val settings2 = DataConnectSettings(sslEnabled = false) + + assertThat(settings1.hashCode()).isNotEqualTo(settings2.hashCode()) + } + + @Test + fun `copy() should return a new, equal object when invoked with no explicit arguments`() { + val settings = DataConnectSettings() + val settings2 = settings.copy() + + assertThat(settings2).isNotSameInstanceAs(settings) + assertThat(settings2.host).isSameInstanceAs(settings.host) + assertThat(settings2.sslEnabled).isEqualTo(settings.sslEnabled) + } + + @Test + fun `copy() should return an object with the given 'host'`() { + val settings = DataConnectSettings() + val newHost = settings.host + "ZZZZ" + + val settings2 = settings.copy(host = newHost) + + assertThat(settings2.host).isSameInstanceAs(newHost) + assertThat(settings2.sslEnabled).isEqualTo(settings.sslEnabled) + } + + @Test + fun `copy() should return an object with the given 'sslEnabled'`() { + val settings = DataConnectSettings() + val newSslEnabled = !settings.sslEnabled + + val settings2 = settings.copy(sslEnabled = newSslEnabled) + + assertThat(settings2.host).isSameInstanceAs(settings.host) + assertThat(settings2.sslEnabled).isEqualTo(newSslEnabled) + } + + @Test + fun `copy() should return an object with properties set to all given arguments`() { + val settings = DataConnectSettings() + val newHost = settings.host + "ZZZZ" + val newSslEnabled = !settings.sslEnabled + + val settings2 = settings.copy(host = newHost, sslEnabled = newSslEnabled) + + assertThat(settings2.host).isSameInstanceAs(newHost) + assertThat(settings2.sslEnabled).isEqualTo(newSslEnabled) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt new file mode 100644 index 00000000000..3de33c8d916 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.DataConnectError.PathSegment +import org.junit.Test + +class PathSegmentFieldUnitTest { + + @Test + fun `field should equal the value given to the constructor`() { + val segment = PathSegment.Field("foo") + assertThat(segment.field).isEqualTo("foo") + } + + @Test + fun `toString() should equal the field`() { + val segment = PathSegment.Field("foo") + assertThat(segment.toString()).isEqualTo("foo") + } + + @Test + fun `equals() should return true for the same instance`() { + val segment = PathSegment.Field("foo") + assertThat(segment.equals(segment)).isTrue() + } + + @Test + fun `equals() should return true for an equal field`() { + val segment1 = PathSegment.Field("foo") + val segment2 = PathSegment.Field("foo") + assertThat(segment1.equals(segment2)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val segment = PathSegment.Field("foo") + assertThat(segment.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val segment = PathSegment.Field("foo") + assertThat(segment.equals(listOf("foo"))).isFalse() + } + + @Test + fun `equals() should return false for a different field`() { + val segment1 = PathSegment.Field("foo") + val segment2 = PathSegment.Field("bar") + assertThat(segment1.equals(segment2)).isFalse() + } + + @Test + fun `hashCode() should return the same value as the field's hashCode() method`() { + assertThat(PathSegment.Field("foo").hashCode()).isEqualTo("foo".hashCode()) + assertThat(PathSegment.Field("bar").hashCode()).isEqualTo("bar".hashCode()) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt new file mode 100644 index 00000000000..4c4a15d2cd5 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.DataConnectError.PathSegment +import org.junit.Test + +class PathSegmentListIndexUnitTest { + + @Test + fun `index should equal the value given to the constructor`() { + val segment = PathSegment.ListIndex(42) + assertThat(segment.index).isEqualTo(42) + } + + @Test + fun `toString() should equal the field`() { + val segment = PathSegment.ListIndex(42) + assertThat(segment.toString()).isEqualTo("42") + } + + @Test + fun `equals() should return true for the same instance`() { + val segment = PathSegment.ListIndex(42) + assertThat(segment.equals(segment)).isTrue() + } + + @Test + fun `equals() should return true for an equal field`() { + val segment1 = PathSegment.ListIndex(42) + val segment2 = PathSegment.ListIndex(42) + assertThat(segment1.equals(segment2)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val segment = PathSegment.ListIndex(42) + assertThat(segment.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val segment = PathSegment.ListIndex(42) + assertThat(segment.equals(listOf("foo"))).isFalse() + } + + @Test + fun `equals() should return false for a different index`() { + val segment1 = PathSegment.ListIndex(42) + val segment2 = PathSegment.ListIndex(43) + assertThat(segment1.equals(segment2)).isFalse() + } + + @Test + fun `hashCode() should return the same value as the field's hashCode() method`() { + assertThat(PathSegment.ListIndex(42).hashCode()).isEqualTo(42.hashCode()) + assertThat(PathSegment.ListIndex(43).hashCode()).isEqualTo(43.hashCode()) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt new file mode 100644 index 00000000000..fabfca07d56 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt @@ -0,0 +1,738 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class) + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.protobuf.Struct +import com.google.protobuf.Value +import com.google.protobuf.Value.KindCase +import java.util.regex.Pattern +import kotlin.reflect.KClass +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import org.junit.Assert.assertThrows +import org.junit.Test + +class ProtoStructDecoderUnitTest { + + @Test + fun `decodeFromStruct() can encode and decode a complex object A`() { + val obj = + SerializationTestData.AllTheTypes.newInstance(seed = "TheQuickBrown").withEmptyUnitLists() + val struct = encodeToStruct(obj) + val decodedObj = decodeFromStruct(struct) + assertThat(decodedObj).isEqualTo(obj) + } + + @Test + fun `decodeFromStruct() can encode and decode a complex object B`() { + val obj = + SerializationTestData.AllTheTypes.newInstance(seed = "FoxJumpsOver").withEmptyUnitLists() + val struct = encodeToStruct(obj) + val decodedObj = decodeFromStruct(struct) + assertThat(decodedObj).isEqualTo(obj) + } + + @Test + fun `decodeFromStruct() can encode and decode a complex object C`() { + val obj = + SerializationTestData.AllTheTypes.newInstance(seed = "TheLazyDog").withEmptyUnitLists() + val struct = encodeToStruct(obj) + val decodedObj = decodeFromStruct(struct) + assertThat(decodedObj).isEqualTo(obj) + } + + @Test + fun `decodeFromStruct() can encode and decode a list of nullable Unit ending in null`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(null, Unit, null))) + val decodedObj = decodeFromStruct(struct) + assertThat(decodedObj).isEqualTo(TestData(listOf(null, Unit, null))) + } + + @Test + fun `decodeFromStruct() can encode and decode a list of nullable Unit ending in Unit`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(null, Unit, null, Unit))) + val decodedObj = decodeFromStruct(struct) + assertThat(decodedObj).isEqualTo(TestData(listOf(null, Unit, null, Unit))) + } + + @Test + fun `decodeFromStruct() can decode a Struct to Unit`() { + val decodedTestData = decodeFromStruct(Struct.getDefaultInstance()) + assertThat(decodedTestData).isSameInstanceAs(Unit) + } + + @Test + fun `decodeFromStruct() can decode a Struct with String values`() { + @Serializable data class TestData(val value1: String, val value2: String) + val struct = encodeToStruct(TestData(value1 = "foo", value2 = "bar")) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(value1 = "foo", value2 = "bar")) + } + + @Test + fun `decodeFromStruct() can decode a Struct with _nullable_ String values`() { + @Serializable data class TestData(val isNull: String?, val isNotNull: String?) + val struct = encodeToStruct(TestData(isNull = null, isNotNull = "NotNull")) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(isNull = null, isNotNull = "NotNull")) + } + + @Test + fun `decodeFromStruct() can decode a Struct with Boolean values`() { + @Serializable data class TestData(val value1: Boolean, val value2: Boolean) + val struct = encodeToStruct(TestData(value1 = true, value2 = false)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(value1 = true, value2 = false)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with _nullable_ Boolean values`() { + @Serializable data class TestData(val isNull: Boolean?, val isNotNull: Boolean?) + val struct = encodeToStruct(TestData(isNull = null, isNotNull = true)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(isNull = null, isNotNull = true)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with Int values`() { + @Serializable data class TestData(val value1: Int, val value2: Int) + val struct = encodeToStruct(TestData(value1 = 123, value2 = -456)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(value1 = 123, value2 = -456)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with _nullable_ Int values`() { + @Serializable data class TestData(val isNull: Int?, val isNotNull: Int?) + val struct = encodeToStruct(TestData(isNull = null, isNotNull = 42)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(isNull = null, isNotNull = 42)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with extreme Int values`() { + @Serializable data class TestData(val max: Int, val min: Int) + val struct = encodeToStruct(TestData(max = Int.MAX_VALUE, min = Int.MIN_VALUE)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(max = Int.MAX_VALUE, min = Int.MIN_VALUE)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with Double values`() { + @Serializable data class TestData(val value1: Double, val value2: Double) + val struct = encodeToStruct(TestData(value1 = 123.45, value2 = -456.78)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(value1 = 123.45, value2 = -456.78)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with _nullable_ Double values`() { + @Serializable data class TestData(val isNull: Double?, val isNotNull: Double?) + val struct = encodeToStruct(TestData(isNull = null, isNotNull = 987.654)) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(isNull = null, isNotNull = 987.654)) + } + + @Test + fun `decodeFromStruct() can decode a Struct with extreme Double values`() { + @Serializable + data class TestData( + val min: Double, + val max: Double, + val positiveInfinity: Double, + val negativeInfinity: Double, + val nan: Double + ) + val struct = + encodeToStruct( + TestData( + min = Double.MIN_VALUE, + max = Double.MAX_VALUE, + positiveInfinity = Double.POSITIVE_INFINITY, + negativeInfinity = Double.NEGATIVE_INFINITY, + nan = Double.NaN + ) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo( + TestData( + min = Double.MIN_VALUE, + max = Double.MAX_VALUE, + positiveInfinity = Double.POSITIVE_INFINITY, + negativeInfinity = Double.NEGATIVE_INFINITY, + nan = Double.NaN + ) + ) + } + + @Test + fun `decodeFromStruct() can decode a Struct with nested Struct values`() { + @Serializable data class TestDataA(val base: String) + @Serializable data class TestDataB(val dataA: TestDataA) + @Serializable data class TestDataC(val dataB: TestDataB) + @Serializable data class TestDataD(val dataC: TestDataC) + + val struct = encodeToStruct(TestDataD(TestDataC(TestDataB(TestDataA("hello"))))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestDataD(TestDataC(TestDataB(TestDataA("hello"))))) + } + + @Test + fun `decodeFromStruct() can decode a Struct with nested _nullable_ Struct values`() { + @Serializable data class TestDataA(val base: String) + @Serializable data class TestDataB(val dataANull: TestDataA?, val dataANotNull: TestDataA?) + @Serializable data class TestDataC(val dataBNull: TestDataB?, val dataBNotNull: TestDataB?) + @Serializable data class TestDataD(val dataCNull: TestDataC?, val dataCNotNull: TestDataC?) + + val struct = + encodeToStruct(TestDataD(null, TestDataC(null, TestDataB(null, TestDataA("hello"))))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestDataD(null, TestDataC(null, TestDataB(null, TestDataA("hello"))))) + } + + @Test + fun `decodeFromStruct() can decode a Struct with nullable ListValue values`() { + @Serializable data class TestData(val nullList: List?, val nonNullList: List?) + val struct = encodeToStruct(TestData(nullList = null, nonNullList = listOf("a", "b"))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(nullList = null, nonNullList = listOf("a", "b"))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of String`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf("elem1", "elem2"))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf("elem1", "elem2"))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ String`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(null, "aaa", null, "bbb"))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(null, "aaa", null, "bbb"))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of Boolean`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(true, false, true, false))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(true, false, true, false))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ Boolean`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(null, true, false, null, true, false))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(null, true, false, null, true, false))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of Int`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(1, 0, -1, Int.MAX_VALUE, Int.MIN_VALUE))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(1, 0, -1, Int.MAX_VALUE, Int.MIN_VALUE))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ Int`() { + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(1, 0, -1, Int.MAX_VALUE, Int.MIN_VALUE, null))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestData(listOf(1, 0, -1, Int.MAX_VALUE, Int.MIN_VALUE, null))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of Double`() { + @Serializable data class TestData(val list: List) + val struct = + encodeToStruct( + TestData( + listOf( + 1.0, + 0.0, + -0.0, + -1.0, + Double.MAX_VALUE, + Double.MIN_VALUE, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY + ) + ) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo( + TestData( + listOf( + 1.0, + 0.0, + -0.0, + -1.0, + Double.MAX_VALUE, + Double.MIN_VALUE, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY + ) + ) + ) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ Double`() { + @Serializable data class TestData(val list: List) + val struct = + encodeToStruct( + TestData( + listOf( + 1.0, + 0.0, + -0.0, + -1.0, + Double.MAX_VALUE, + Double.MIN_VALUE, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + null + ) + ) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo( + TestData( + listOf( + 1.0, + 0.0, + -0.0, + -1.0, + Double.MAX_VALUE, + Double.MIN_VALUE, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + null + ) + ) + ) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of Struct`() { + @Serializable data class TestDataA(val s1: String, val s2: String?) + @Serializable data class TestData(val list: List) + val struct = encodeToStruct(TestData(listOf(TestDataA("aa", null), TestDataA("bb", null)))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestData(listOf(TestDataA("aa", null), TestDataA("bb", null)))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ Struct`() { + @Serializable data class TestDataA(val s1: String, val s2: String?) + @Serializable data class TestData(val list: List) + val struct = + encodeToStruct(TestData(listOf(null, TestDataA("aa", null), TestDataA("bb", null), null))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestData(listOf(null, TestDataA("aa", null), TestDataA("bb", null), null))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of ListValue`() { + @Serializable data class TestData(val list: List>) + val struct = encodeToStruct(TestData(listOf(listOf(1, 2, 3), listOf(4, 5, 6)))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(listOf(1, 2, 3), listOf(4, 5, 6)))) + } + + @Test + fun `decodeFromStruct() can decode a ListValue of _nullable_ ListValue`() { + @Serializable data class TestData(val list: List?>) + val struct = encodeToStruct(TestData(listOf(listOf(1, 2, 3), listOf(4, 5, 6), null))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData).isEqualTo(TestData(listOf(listOf(1, 2, 3), listOf(4, 5, 6), null))) + } + + @Test + fun `decodeFromStruct() can decode a Struct with Inline values`() { + @Serializable data class TestData(val s: TestStringValueClass, val i: TestIntValueClass) + val struct = encodeToStruct(TestData(TestStringValueClass("TestString"), TestIntValueClass(42))) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestData(TestStringValueClass("TestString"), TestIntValueClass(42))) + } + + @Test + fun `decodeFromStruct() can decode a Struct with _nullable_ Inline values`() { + @Serializable + data class TestData( + val s: TestStringValueClass?, + val snull: TestStringValueClass?, + val i: TestIntValueClass?, + val inull: TestIntValueClass? + ) + val struct = + encodeToStruct( + TestData(TestStringValueClass("TestString"), null, TestIntValueClass(42), null) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo(TestData(TestStringValueClass("TestString"), null, TestIntValueClass(42), null)) + } + + @Test + fun `decodeFromStruct() can decode a ListValue with Inline values`() { + @Serializable + data class TestData(val s: List, val i: List) + val struct = + encodeToStruct( + TestData( + listOf(TestStringValueClass("TestString1"), TestStringValueClass("TestString2")), + listOf(TestIntValueClass(42), TestIntValueClass(43)) + ) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo( + TestData( + listOf(TestStringValueClass("TestString1"), TestStringValueClass("TestString2")), + listOf(TestIntValueClass(42), TestIntValueClass(43)) + ) + ) + } + + @Test + fun `decodeFromStruct() can decode a ListValue with _nullable_ Inline values`() { + @Serializable + data class TestData(val s: List, val i: List) + val struct = + encodeToStruct( + TestData( + listOf(TestStringValueClass("TestString1"), null, TestStringValueClass("TestString2")), + listOf(TestIntValueClass(42), null, TestIntValueClass(43)) + ) + ) + + val decodedTestData = decodeFromStruct(struct) + + assertThat(decodedTestData) + .isEqualTo( + TestData( + listOf(TestStringValueClass("TestString1"), null, TestStringValueClass("TestString2")), + listOf(TestIntValueClass(42), null, TestIntValueClass(43)) + ) + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if attempting to decode an Int`() { + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.NUMBER_VALUE, + actualKind = KindCase.STRUCT_VALUE, + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if attempting to decode a Double`() { + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.NUMBER_VALUE, + actualKind = KindCase.STRUCT_VALUE, + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if attempting to decode a Boolean`() { + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.BOOL_VALUE, + actualKind = KindCase.STRUCT_VALUE + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if attempting to decode a String`() { + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.STRING_VALUE, + actualKind = KindCase.STRUCT_VALUE + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if attempting to decode a List`() { + assertDecodeFromStructThrowsIncorrectKindCase>( + expectedKind = KindCase.LIST_VALUE, + actualKind = KindCase.STRUCT_VALUE + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a Boolean value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: String) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData(val someValue: Boolean) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.BOOL_VALUE, + actualKind = KindCase.STRING_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData("foo"))), + actualValue = "foo", + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding an Int value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: String) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData(val someValue: Int) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.NUMBER_VALUE, + actualKind = KindCase.STRING_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData("foo"))), + actualValue = "foo", + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a Double value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: String) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData(val someValue: Double) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.NUMBER_VALUE, + actualKind = KindCase.STRING_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData("foo"))), + actualValue = "foo", + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a String value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: Int) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData(val someValue: String) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.STRING_VALUE, + actualKind = KindCase.NUMBER_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData(42))), + actualValue = 42.0, + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a List value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: Boolean) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData(val someValue: List) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.LIST_VALUE, + actualKind = KindCase.BOOL_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData(true))), + actualValue = true, + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a Struct value found a different type`() { + @Serializable data class TestEncodeSubData(val someValue: Int) + @Serializable data class TestEncodeData(val aaa: TestEncodeSubData) + @Serializable data class TestDecodeSubData2(val someValue: Int) + @Serializable data class TestDecodeSubData(val someValue: TestDecodeSubData2) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.STRUCT_VALUE, + actualKind = KindCase.NUMBER_VALUE, + struct = encodeToStruct(TestEncodeData(TestEncodeSubData(42))), + actualValue = 42.0, + path = "aaa.someValue" + ) + } + + @Test + fun `decodeFromStruct() should throw SerializationException if decoding a Struct value found null`() { + @Serializable data class TestDecodeSubData2(val someValue: String) + @Serializable data class TestDecodeSubData(val bbb: TestDecodeSubData2) + @Serializable data class TestDecodeData(val aaa: TestDecodeSubData) + val struct = buildStructProto { putStruct("aaa") { putNull("bbb") } } + + assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind = KindCase.STRUCT_VALUE, + actualKind = KindCase.NULL_VALUE, + struct = struct, + actualValue = null, + path = "aaa.bbb" + ) + } + + private enum class TestEnum { + A, + B, + C, + D + } + + @Serializable @JvmInline private value class TestStringValueClass(val a: String) + + @Serializable @JvmInline private value class TestIntValueClass(val a: Int) + + @Serializable @JvmInline private value class TestByteValueClass(val a: Byte) + + // TODO: Add tests for decoding to objects with unsupported field types (e.g. Byte, Char) and + // list elements of unsupported field types (e.g. Byte, Char). + +} + +/** + * Asserts that `decodeFromStruct` throws [SerializationException], with a message that indicates + * that the "kind" of the [Value] being decoded differed from what was expected. + * + * @param expectedKind The expected "kind" of the [Value] being decoded that should be incorporated + * into the exception's message. + * @param actualKind The actual "kind" of the [Value] being decoded that should be incorporated into + * the exception's message. + */ +private inline fun assertDecodeFromStructThrowsIncorrectKindCase( + expectedKind: KindCase, + actualKind: KindCase, + actualValue: Any? = Struct.getDefaultInstance().fieldsMap, + struct: Struct = Struct.getDefaultInstance(), + path: String? = null +) { + val exception = assertThrows(SerializationException::class.java) { decodeFromStruct(struct) } + // The error message is expected to look something like this: + // "expected NUMBER_VALUE, but got STRUCT_VALUE" + assertThat(exception).hasMessageThat().ignoringCase().contains("expected $expectedKind") + assertThat(exception).hasMessageThat().ignoringCase().contains("got $actualKind") + assertThat(exception).hasMessageThat().ignoringCase().contains("($actualValue)") + if (path !== null) { + assertThat(exception).hasMessageThat().ignoringCase().contains("decoding \"$path\"") + } +} + +/** + * Asserts that `decodeFromStruct` throws [SerializationException], with a message that indicates + * that the type `T` being decoded is not supported. + * + * @param expectedTypeInMessage The type that the exception's message should indicate is not + * supported; if not specified, use `T`. Note that the only case where this argument's value should + * be anything _other_ than `T` is for _value classes_ that are mapped to a primitive type. + */ +private inline fun assertThrowsNotSupported( + expectedTypeInMessage: KClass<*> = T::class +) { + val exception = + assertThrows(SerializationException::class.java) { + decodeFromStruct(Struct.getDefaultInstance()) + } + assertThat(exception) + .hasMessageThat() + .containsMatch( + Pattern.compile( + "decoding.*${Pattern.quote(expectedTypeInMessage.qualifiedName!!)}.*not supported", + Pattern.CASE_INSENSITIVE + ) + ) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructEncoderUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructEncoderUnitTest.kt new file mode 100644 index 00000000000..c73a31aa732 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructEncoderUnitTest.kt @@ -0,0 +1,398 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalSerializationApi::class) + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.protobuf.Struct +import java.util.concurrent.atomic.AtomicLong +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.serializer +import org.junit.Assert.assertThrows +import org.junit.Test + +class ProtoStructEncoderUnitTest { + + @Test + fun `encodeToStruct() should throw if a NUMBER_VALUE is produced`() { + val exception = assertThrows(IllegalArgumentException::class.java) { encodeToStruct(42) } + assertThat(exception).hasMessageThat().contains("NUMBER_VALUE") + } + + @Test + fun `encodeToStruct() should throw if a BOOL_VALUE is produced`() { + val exception = assertThrows(IllegalArgumentException::class.java) { encodeToStruct(true) } + assertThat(exception).hasMessageThat().contains("BOOL_VALUE") + } + + @Test + fun `encodeToStruct() should throw if a STRING_VALUE is produced`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + encodeToStruct("arbitrary string value") + } + assertThat(exception).hasMessageThat().contains("STRING_VALUE") + } + + @Test + fun `encodeToStruct() should throw if a LIST_VALUE is produced`() { + val exception = + assertThrows(IllegalArgumentException::class.java) { + encodeToStruct(listOf("element1", "element2")) + } + assertThat(exception).hasMessageThat().contains("LIST_VALUE") + } + + @Test + fun `encodeToStruct() should return an empty struct if an empty map is given`() { + val encodedStruct = encodeToStruct(emptyMap()) + assertThat(encodedStruct).isEqualToDefaultInstance() + } + + @Test + fun `encodeToStruct() should encode Unit as an empty struct`() { + val encodedStruct = encodeToStruct(Unit) + assertThat(encodedStruct).isEqualToDefaultInstance() + } + + @Test + fun `encodeToStruct() should encode an class with all primitive types`() { + @Serializable + data class TestData( + val iv: Int, + val dv: Double, + val bvt: Boolean, + val bvf: Boolean, + val sv: String, + val nsvn: String?, + val nsvnn: String? + ) + val encodedStruct = + encodeToStruct( + TestData( + iv = 42, + dv = 1234.5, + bvt = true, + bvf = false, + sv = "blah blah", + nsvn = null, + nsvnn = "I'm not null" + ) + ) + + assertThat(encodedStruct) + .isEqualTo( + buildStructProto { + put("iv", 42.0) + put("dv", 1234.5) + put("bvt", true) + put("bvf", false) + put("sv", "blah blah") + putNull("nsvn") + put("nsvnn", "I'm not null") + } + ) + } + + @Test + fun `encodeToStruct() should encode lists with all primitive types`() { + @Serializable + data class TestData( + val iv: List, + val dv: List, + val bv: List, + val sv: List, + val nsv: List + ) + val encodedStruct = + encodeToStruct( + TestData( + iv = listOf(42, 43), + dv = listOf(1234.5, 5678.9), + bv = listOf(true, false, false, true), + sv = listOf("abcde", "fghij"), + nsv = listOf("klmno", null, "pqrst", null) + ) + ) + + assertThat(encodedStruct) + .isEqualTo( + buildStructProto { + putList("iv") { + add(42.0) + add(43.0) + } + putList("dv") { + add(1234.5) + add(5678.9) + } + putList("bv") { + add(true) + add(false) + add(false) + add(true) + } + putList("sv") { + add("abcde") + add("fghij") + } + putList("nsv") { + add("klmno") + addNull() + add("pqrst") + addNull() + } + } + ) + } + + @Test + fun `encodeToStruct() should support nested composite types`() { + @Serializable data class TestData3(val s: String) + @Serializable data class TestData2(val data3: TestData3, val data3N: TestData3?) + @Serializable data class TestData1(val data2: TestData2) + val encodedStruct = encodeToStruct(TestData1(TestData2(TestData3("zzzz"), null))) + + assertThat(encodedStruct) + .isEqualTo( + buildStructProto { + putStruct("data2") { + putNull("data3N") + putStruct("data3") { put("s", "zzzz") } + } + } + ) + } + + @Test + fun `encodeToStruct() should support OptionalVariable Undefined when T is not nullable`() { + @Serializable data class TestData(val s: OptionalVariable) + + val encodedStruct = encodeToStruct(TestData(OptionalVariable.Undefined)) + + assertThat(encodedStruct).isEqualTo(Struct.getDefaultInstance()) + } + + @Test + fun `encodeToStruct() should support OptionalVariable Undefined when T is nullable`() { + @Serializable data class TestData(val s: OptionalVariable) + + val encodedStruct = encodeToStruct(TestData(OptionalVariable.Undefined)) + + assertThat(encodedStruct).isEqualTo(Struct.getDefaultInstance()) + } + + @Test + fun `encodeToStruct() should support OptionalVariable Value when T is not nullable`() { + @Serializable data class TestData(val s: OptionalVariable) + + val encodedStruct = encodeToStruct(TestData(OptionalVariable.Value("Hello"))) + + assertThat(encodedStruct).isEqualTo(buildStructProto { put("s", "Hello") }) + } + + @Test + fun `encodeToStruct() should support OptionalVariable Value when T is nullable but not null`() { + @Serializable data class TestData(val s: OptionalVariable) + + val encodedStruct = encodeToStruct(TestData(OptionalVariable.Value("World"))) + + assertThat(encodedStruct).isEqualTo(buildStructProto { put("s", "World") }) + } + + @Test + fun `encodeToStruct() should support OptionalVariable Value when T is nullable and null`() { + @Serializable data class TestData(val s: OptionalVariable) + + val encodedStruct = encodeToStruct(TestData(OptionalVariable.Value(null))) + + assertThat(encodedStruct).isEqualTo(buildStructProto { putNull("s") }) + } +} + +/** + * An encoder that can be useful during testing to simply print the method invocations in order to + * discover how an encoder should be implemented. + */ +@Suppress("unused") +private class LoggingEncoder( + private val idBySerialDescriptor: MutableMap = mutableMapOf() +) : Encoder, CompositeEncoder { + val id = nextEncoderId.incrementAndGet() + + override val serializersModule = EmptySerializersModule() + + private fun log(message: String) { + println("zzyzx LoggingEncoder[$id] $message") + } + + private fun idFor(descriptor: SerialDescriptor) = + idBySerialDescriptor[descriptor] + ?: nextSerialDescriptorId.incrementAndGet().also { idBySerialDescriptor[descriptor] = it } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { + log( + "beginStructure() descriptorId=${idFor(descriptor)} kind=${descriptor.kind} " + + "elementsCount=${descriptor.elementsCount}" + ) + return LoggingEncoder(idBySerialDescriptor) + } + + override fun endStructure(descriptor: SerialDescriptor) { + log("endStructure() descriptorId=${idFor(descriptor)} kind=${descriptor.kind}") + } + + override fun encodeBoolean(value: Boolean) { + log("encodeBoolean($value)") + } + + override fun encodeByte(value: Byte) { + log("encodeByte($value)") + } + + override fun encodeChar(value: Char) { + log("encodeChar($value)") + } + + override fun encodeDouble(value: Double) { + log("encodeDouble($value)") + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + log("encodeEnum($index)") + } + + override fun encodeFloat(value: Float) { + log("encodeFloat($value)") + } + + override fun encodeInline(descriptor: SerialDescriptor): Encoder { + log("encodeInline() kind=${descriptor.kind} serialName=${descriptor.serialName}") + return LoggingEncoder(idBySerialDescriptor) + } + + override fun encodeInt(value: Int) { + log("encodeInt($value)") + } + + override fun encodeLong(value: Long) { + log("encodeLong($value)") + } + + @ExperimentalSerializationApi + override fun encodeNull() { + log("encodeNull()") + } + + override fun encodeShort(value: Short) { + log("encodeShort($value)") + } + + override fun encodeString(value: String) { + log("encodeString($value)") + } + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + log("encodeBooleanElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) { + log("encodeByteElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) { + log("encodeCharElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + log("encodeDoubleElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + log("encodeFloatElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder { + log("encodeInlineElement() index=$index elementName=${descriptor.getElementName(index)}") + return LoggingEncoder(idBySerialDescriptor) + } + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + log("encodeIntElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + log("encodeLongElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) { + log("encodeShortElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + log("encodeStringElement($value) index=$index elementName=${descriptor.getElementName(index)}") + } + + @ExperimentalSerializationApi + override fun encodeNullableSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T? + ) { + log( + "encodeNullableSerializableElement($value) index=$index elementName=${descriptor.getElementName(index)}" + ) + if (value != null) { + encodeSerializableValue(serializer, value) + } + } + + override fun encodeSerializableElement( + descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T + ) { + log( + "encodeSerializableElement($value) index=$index elementName=${descriptor.getElementName(index)}" + ) + encodeSerializableValue(serializer, value) + } + + companion object { + + fun encode(serializer: SerializationStrategy, value: T) { + LoggingEncoder().encodeSerializableValue(serializer, value) + } + + inline fun encode(value: T) = encode(serializer(), value) + + private val nextEncoderId = AtomicLong(0) + private val nextSerialDescriptorId = AtomicLong(998800000L) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/SerializationTestData.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/SerializationTestData.kt new file mode 100644 index 00000000000..37fe6a07109 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/SerializationTestData.kt @@ -0,0 +1,288 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import kotlin.math.PI +import kotlin.math.abs +import kotlinx.serialization.Serializable + +object SerializationTestData { + + enum class TestEnum { + A, + B, + C, + D, + } + + @Serializable @JvmInline value class TestStringValueClass(val a: String) + + @Serializable @JvmInline value class TestIntValueClass(val a: Int) + + @Serializable + data class TestData1(val s: String, val i: Int) { + companion object { + fun newInstance(seed: String = "abcdef01234567890"): TestData1 = + seed.run { TestData1(s = seededString("s"), i = seededInt("i")) } + } + } + + @Serializable + data class TestData2(val td: TestData1, val ntd: TestData1?, val noll: TestData1?) { + companion object { + fun newInstance(seed: String = "abcdef01234567890"): TestData2 = + TestData2( + td = TestData1.newInstance(seed + "td"), + ntd = TestData1.newInstance(seed + "ntd"), + noll = null + ) + } + } + + @Serializable + data class AllTheTypes( + val boolean: Boolean, + val byte: Byte, + val char: Char, + val double: Double, + val doubleMinValue: Double, + val doubleMaxValue: Double, + val doubleNegativeInfinity: Double, + val doublePositiveInfinity: Double, + val doubleNaN: Double, + val enum: TestEnum, + val float: Float, + val floatMinValue: Float, + val floatMaxValue: Float, + val floatNegativeInfinity: Float, + val floatPositiveInfinity: Float, + val floatNaN: Float, + val inlineString: TestStringValueClass, + val inlineInt: TestIntValueClass, + val int: Int, + val long: Long, + val noll: Unit?, + val short: Short, + val string: String, + val testData: TestData2, + val booleanList: List, + val byteList: List, + val charList: List, + val doubleList: List, + val enumList: List, + val floatList: List, + val inlineStringList: List, + val inlineIntList: List, + val intList: List, + val longList: List, + val shortList: List, + val stringList: List, + val testDataList: List, + val booleanNull: Boolean?, + val byteNull: Byte?, + val charNull: Char?, + val doubleNull: Double?, + val enumNull: TestEnum?, + val floatNull: Float?, + val inlineStringNull: TestStringValueClass?, + val inlineIntNull: TestIntValueClass?, + val intNull: Int?, + val longNull: Long?, + val shortNull: Short?, + val stringNull: String?, + val testDataNull: TestData2?, + val booleanNullable: Boolean?, + val byteNullable: Byte?, + val charNullable: Char?, + val doubleNullable: Double?, + val enumNullable: TestEnum?, + val floatNullable: Float?, + val inlineStringNullable: TestStringValueClass?, + val inlineIntNullable: TestIntValueClass?, + val intNullable: Int?, + val longNullable: Long?, + val shortNullable: Short?, + val stringNullable: String?, + val testDataNullable: TestData2?, + val booleanNullableList: List, + val byteNullableList: List, + val charNullableList: List, + val doubleNullableList: List, + val enumNullableList: List, + val floatNullableList: List, + val inlineStringNullableList: List, + val inlineIntNullableList: List, + val intNullableList: List, + val longNullableList: List, + val shortNullableList: List, + val stringNullableList: List, + val testDataNullableList: List, + val nested: AllTheTypes?, + val unit: Unit, + val nullUnit: Unit?, + val nullableUnit: Unit?, + val listOfUnit: List, + val listOfNullableUnit: List, + ) { + companion object { + + fun newInstance(seed: String = "abcdef01234567890", nesting: Int = 1): AllTheTypes = + seed.run { + AllTheTypes( + boolean = seededBoolean("plain"), + byte = seededByte("plain"), + char = seededChar("plain"), + double = seededDouble("plain"), + doubleMinValue = Double.MIN_VALUE, + doubleMaxValue = Double.MAX_VALUE, + doubleNegativeInfinity = Double.POSITIVE_INFINITY, + doublePositiveInfinity = Double.NEGATIVE_INFINITY, + doubleNaN = Double.NaN, + enum = seededEnum("plain"), + float = seededFloat("plain"), + floatMinValue = Float.MIN_VALUE, + floatMaxValue = Float.MAX_VALUE, + floatNegativeInfinity = Float.POSITIVE_INFINITY, + floatPositiveInfinity = Float.NEGATIVE_INFINITY, + floatNaN = Float.NaN, + inlineString = TestStringValueClass(seededString("value")), + inlineInt = TestIntValueClass(seededInt("value")), + int = seededInt("plain"), + long = seededLong("plain"), + noll = null, + short = seededShort("plain"), + string = seededString("plain"), + testData = TestData2.newInstance(seededString("plain")), + booleanList = listOf(seededBoolean("list0"), seededBoolean("list1")), + byteList = listOf(seededByte("list0"), seededByte("list1")), + charList = listOf(seededChar("list0"), seededChar("list1")), + doubleList = listOf(seededDouble("list0"), seededDouble("list1")), + enumList = listOf(seededEnum("list0"), seededEnum("list1")), + floatList = listOf(seededFloat("list0"), seededFloat("list1")), + inlineStringList = + listOf( + TestStringValueClass(seededString("list0")), + TestStringValueClass(seededString("list1")) + ), + inlineIntList = + listOf(TestIntValueClass(seededInt("list0")), TestIntValueClass(seededInt("list1"))), + intList = listOf(seededInt("list0"), seededInt("list1")), + longList = listOf(seededLong("list0"), seededLong("list1")), + shortList = listOf(seededShort("list0"), seededShort("list1")), + stringList = listOf(seededString("list0"), seededString("list1")), + testDataList = + listOf( + TestData2.newInstance(seededString("list0")), + TestData2.newInstance(seededString("list1")) + ), + booleanNull = null, + byteNull = null, + charNull = null, + doubleNull = null, + enumNull = null, + floatNull = null, + inlineStringNull = null, + inlineIntNull = null, + intNull = null, + longNull = null, + shortNull = null, + stringNull = null, + testDataNull = null, + booleanNullable = seededBoolean("nullable"), + byteNullable = seededByte("nullable"), + charNullable = seededChar("nullable"), + doubleNullable = seededDouble("nullable"), + enumNullable = seededEnum("nullable"), + floatNullable = seededFloat("nullable"), + inlineStringNullable = TestStringValueClass(seededString("nullable")), + inlineIntNullable = TestIntValueClass(seededInt("nullable")), + intNullable = seededInt("nullable"), + longNullable = seededLong("nullable"), + shortNullable = seededShort("nullable"), + stringNullable = seededString("nullable"), + testDataNullable = TestData2.newInstance(seededString("nullable")), + booleanNullableList = listOf(seededBoolean("nlist0"), seededBoolean("nlist1"), null), + byteNullableList = listOf(seededByte("nlist0"), seededByte("nlist1"), null), + charNullableList = listOf(seededChar("nlist0"), seededChar("nlist1"), null), + doubleNullableList = listOf(seededDouble("nlist0"), seededDouble("nlist1"), null), + enumNullableList = listOf(seededEnum("nlist0"), seededEnum("nlist1"), null), + floatNullableList = listOf(seededFloat("nlist0"), seededFloat("nlist1"), null), + inlineStringNullableList = + listOf( + TestStringValueClass(seededString("nlist0")), + TestStringValueClass(seededString("nlist1")), + null + ), + inlineIntNullableList = + listOf( + TestIntValueClass(seededInt("nlist0")), + TestIntValueClass(seededInt("nlist1")), + null + ), + intNullableList = listOf(seededInt("nlist0"), seededInt("nlist1"), null), + longNullableList = listOf(seededLong("nlist0"), seededLong("nlist1"), null), + shortNullableList = listOf(seededShort("nlist0"), seededShort("nlist1"), null), + stringNullableList = listOf(seededString("nlist0"), seededString("nlist1"), null), + testDataNullableList = + listOf( + TestData2.newInstance(seededString("nlist0")), + TestData2.newInstance(seededString("nlist1")) + ), + nested = if (nesting <= 0) null else newInstance("${seed}nest${nesting}", nesting - 1), + unit = Unit, + nullUnit = null, + nullableUnit = Unit, + listOfUnit = listOf(Unit, Unit), + listOfNullableUnit = listOf(Unit, null, Unit, null), + ) + } + } + } +} + +/** + * Creates and returns a new instance with the exact same property values but with all lists of + * [Unit] to be empty. This may be useful if testing an encoder/decoder that does not support + * [kotlinx.serialization.descriptors.StructureKind.OBJECT] in lists. + */ +fun SerializationTestData.AllTheTypes.withEmptyUnitLists(): SerializationTestData.AllTheTypes = + copy( + listOfUnit = emptyList(), + listOfNullableUnit = emptyList(), + nested = nested?.withEmptyUnitLists() + ) + +private fun String.seededBoolean(id: String): Boolean = seededInt(id) % 2 == 0 + +private fun String.seededByte(id: String): Byte = seededInt(id).toByte() + +private fun String.seededChar(id: String): Char = get(abs(id.hashCode()) % length) + +private fun String.seededDouble(id: String): Double = seededLong(id).toDouble() / PI + +private fun String.seededEnum(id: String): SerializationTestData.TestEnum = + SerializationTestData.TestEnum.values().let { it[abs(seededInt(id)) % it.size] } + +private fun String.seededFloat(id: String): Float = (seededInt(id).toFloat() / PI.toFloat()) + +private fun String.seededInt(id: String): Int = (hashCode() * id.hashCode()) + +private fun String.seededLong(id: String): Long = (hashCode().toLong() * id.hashCode().toLong()) + +private fun String.seededShort(id: String): Short = seededInt(id).toShort() + +private fun String.seededString(id: String): String = "${this}_${id}" diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/UtilUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/UtilUnitTest.kt new file mode 100644 index 00000000000..4aa1cde83df --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/UtilUnitTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.util.AlphanumericStringUtil.toAlphaNumericString +import org.junit.Test + +class UtilUnitTest { + + @Test + fun `ByteArray toAlphaNumericString() interprets the alphabet`() { + val byteArray = + byteArrayOf( + 0, + 68, + 50, + 20, + -57, + 66, + 84, + -74, + 53, + -49, + -124, + 101, + 58, + 86, + -41, + -58, + 117, + -66, + 119, + -33 + ) + // This string is `ALPHANUMERIC_ALPHABET` in `Util.kt` + assertThat(byteArray.toAlphaNumericString()).isEqualTo("23456789abcdefghjkmnopqrstuvwxyz") + } + + @Test + fun `ByteArray toAlphaNumericString() where the final 5-bit chunk is 1 bit`() { + byteArrayOf(75, 50).let { assertThat(it.toAlphaNumericString()).isEqualTo("bet2") } + byteArrayOf(75, 51).let { assertThat(it.toAlphaNumericString()).isEqualTo("bet3") } + } + + @Test + fun `ByteArray toAlphaNumericString() where the final 5-bit chunk is 2 bits`() { + byteArrayOf(117, -40, -116, -66, -105, -61, 18, -117, -52).let { + assertThat(it.toAlphaNumericString()).isEqualTo("greathorsebarn2") + } + byteArrayOf(117, -40, -116, -66, -105, -61, 18, -117, -49).let { + assertThat(it.toAlphaNumericString()).isEqualTo("greathorsebarn5") + } + } + + @Test + fun `ByteArray toAlphaNumericString() where the final 5-bit chunk is 3 bits`() { + byteArrayOf(64).let { assertThat(it.toAlphaNumericString()).isEqualTo("a2") } + byteArrayOf(71).let { assertThat(it.toAlphaNumericString()).isEqualTo("a9") } + } + + @Test + fun `ByteArray toAlphaNumericString() where the final 5-bit chunk is 4 bits`() { + byteArrayOf(-58, 117, 48).let { assertThat(it.toAlphaNumericString()).isEqualTo("stun2") } + byteArrayOf(-58, 117, 63).let { assertThat(it.toAlphaNumericString()).isEqualTo("stunh") } + } + + @Test + fun `ByteArray toAlphaNumericString() on empty byte array`() { + val emptyByteArray = byteArrayOf() + assertThat(emptyByteArray.toAlphaNumericString()).isEqualTo("") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value 0`() { + val byteArray = byteArrayOf(0) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("22") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value 1`() { + val byteArray = byteArrayOf(1) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("23") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value 0xff`() { + val byteArray = byteArrayOf(0xff.toByte()) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("z9") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value -1`() { + val byteArray = byteArrayOf(-1) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("z9") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value MIN_VALUE`() { + val byteArray = byteArrayOf(Byte.MIN_VALUE) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("j2") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array with 1 element of value MAX_VALUE`() { + val byteArray = byteArrayOf(Byte.MAX_VALUE) + assertThat(byteArray.toAlphaNumericString()).isEqualTo("h9") + } + + @Test + fun `ByteArray toAlphaNumericString() on byte array containing all possible values`() { + val byteArray = + buildList { + for (i in 0 until 512) { + add(i.toByte()) + } + } + .toByteArray() + assertThat(byteArray.toAlphaNumericString()) + .isEqualTo( + "222j62s62o52g42b3a7js5ag3wa346jn4jcke7ss56f3q92x5shm2ab46em4cbk972omocte7or4ye3k8atnafbq" + + "8ww5mgkv9jynwhu2a7368k47at5ojmccbf86unmhc3ap6ouocpd7gq4tdbfpsrcydxj84sn5ekmqetvaf7p8qv" + + "5fftrr2wdmgfu9cxnrh3wroyvwhpz9z263jc3sb3e8jy6an4odkm8sx5wjm8bb976pmudtk8eunggbv9ozo4ju" + + "7ax6oqnchc7bpcputdfgpysd5epnqmuvffxsr8xdrh7xruzw3jg4sh4edkq9t56wpmyetr9ezo8kudbxbpgquz" + + "efnqqvvngxxrz2w9kg9t97wvnykuhcxhqgvvrhy5sz7wzoyrvhhy9tzdxztzhyzw2242j52j4je3sa3672q52f" + + "3s9k26am4ec3c7jr52eko8sw5oh3ya336akmabb86wo4mckd7jqmwdtj86t58f3p8svnjgbu9ey5uhkza32o6j" + + "u6ap56gm4bbb7osncgbxa74omnckcpepusd7f7qr4xdthq2sd4efm8ctn9f3oqouvefpr8yw5kgbtraxdqgxw9" + + "mynvhkyrwzw2j83a9367ju5sk4eckg8av5ohm4at76womqdbh86tncftt9eynyjc5ap5ommufbxap8pcrd7fpu" + + "rv3efmqguddfprr4wvpgxwrqzdzj83sd3wbkg8sz6enmqdtn8wxnyju9bf9p8puvdxkqguvhgfvrqzw5jy7sz6" + + "wrnghu9bxdpytvhgxzsh5wrnynuzfxzsz9xhrz9xzvz3" + ) + } +} + +/* +The Python script below can be used to generate the byte arrays. + +Just replace the argument to toBase32BitString() with the string you want to encode. If the length +of the resulting bit string is not a multiple of 8 then you will need to pad the string, like this: + bitstring = toBase32BitString("aa") + "000000" # Add zeroes to pad the string to a valid length + +import io + +ALPHABET = "23456789abcdefghjkmnopqrstuvwxyz" + +def toBase32BitString(s): + buf = io.StringIO() + for c in s: + alphabetIndex = ALPHABET.index(c) + buf.write(f"{alphabetIndex:05b}") + return buf.getvalue() + +bitstring = toBase32BitString("badmoods") + +values = [] +for i in range(0, len(bitstring), 8): + chunk = bitstring[i:i+8] + if len(chunk) != 8: + raise ValueError(f"invalid chunk size at {i}: {len(chunk)} (expected exactly 8)") + intvalue = int(chunk, 2) + values.append(intvalue if intvalue <= 127 else (intvalue - 256)) + +print(values) +*/ diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt new file mode 100644 index 00000000000..b601bc11478 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt @@ -0,0 +1,619 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.google.firebase.dataconnect.core + +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.firebase.auth.GetTokenResult +import com.google.firebase.auth.internal.IdTokenListener +import com.google.firebase.auth.internal.InternalAuthProvider +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.core.Globals.toScrubbedAccessToken +import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule +import com.google.firebase.dataconnect.testutil.DelayedDeferred +import com.google.firebase.dataconnect.testutil.ImmediateDeferred +import com.google.firebase.dataconnect.testutil.SuspendingCountDownLatch +import com.google.firebase.dataconnect.testutil.UnavailableDeferred +import com.google.firebase.dataconnect.testutil.accessToken +import com.google.firebase.dataconnect.testutil.newBackgroundScopeThatAdvancesLikeForeground +import com.google.firebase.dataconnect.testutil.newMockLogger +import com.google.firebase.dataconnect.testutil.requestId +import com.google.firebase.dataconnect.testutil.shouldHaveLoggedAtLeastOneMessageContaining +import com.google.firebase.dataconnect.testutil.shouldHaveLoggedExactlyOneMessageContaining +import com.google.firebase.dataconnect.testutil.shouldNotHaveLoggedAnyMessagesContaining +import com.google.firebase.inject.Deferred.DeferredHandler +import com.google.firebase.internal.api.FirebaseNoSignedInUserException +import io.kotest.assertions.asClue +import io.kotest.assertions.nondeterministic.continually +import io.kotest.assertions.nondeterministic.eventually +import io.kotest.assertions.nondeterministic.eventuallyConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.throwable.shouldHaveMessage +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next +import io.mockk.coEvery +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.excludeRecords +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Rule +import org.junit.Test + +private typealias DeferredInternalAuthProvider = + com.google.firebase.inject.Deferred + +class DataConnectAuthUnitTest { + + @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + + private val key = "qqddxntcwk" + private val rs = RandomSource.default() + private val accessTokenGenerator = Arb.accessToken(key) + private val accessToken: String = accessTokenGenerator.next(rs) + private val requestId = Arb.requestId(key).next(rs) + private val mockInternalAuthProvider: InternalAuthProvider = + mockk(relaxed = true, name = "mockInternalAuthProvider-$key") { + excludeRecords { this@mockk.toString() } + } + private val mockLogger = newMockLogger(key) + + @Test + fun `close() should succeed if called _before_ initialize()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.close() + } + + @Test + fun `close() should succeed if called _after_ initialize()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + + dataConnectAuth.close() + } + + @Test + fun `close() should log a message`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + + dataConnectAuth.close() + + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("close()") + } + + @Test + fun `close() should cancel in-flight requests to get a token`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + + coEvery { mockInternalAuthProvider.getAccessToken(any()) } coAnswers + { + dataConnectAuth.close() + taskForToken( + "wz44t6wqz7 SHOULD NOT GET HERE" + + " because join() should have thrown CancellationException" + ) + } + + val exception = shouldThrow { dataConnectAuth.getToken(requestId) } + + exception shouldHaveMessage "getToken() was cancelled, likely by close()" + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("throws GetTokenCancelledException") + } + + @Test + fun `close() should remove the IdTokenListener`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + + val idTokenListenerSlot = slot() + verify { mockInternalAuthProvider.addIdTokenListener(capture(idTokenListenerSlot)) } + val idTokenListener = idTokenListenerSlot.captured + + dataConnectAuth.close() + + verify { mockInternalAuthProvider.removeIdTokenListener(idTokenListener) } + } + + @Test + fun `close() should be callable multiple times, from multiple threads`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + + val latch = SuspendingCountDownLatch(100) + val jobs = + List(latch.count) { + backgroundScope.async(Dispatchers.IO) { + latch.run { + countDown() + await() + } + dataConnectAuth.close() + } + } + + // Await each job to make sure that each invocation returns successfully. + jobs.forEach { it.await() } + } + + @Test + fun `forceRefresh() should throw if invoked before initialize()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + + val exception = shouldThrow { dataConnectAuth.forceRefresh() } + + exception shouldHaveMessage "forceRefresh() cannot be called before initialize()" + } + + @Test + fun `forceRefresh() should do nothing if invoked after close()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.close() + + dataConnectAuth.forceRefresh() + } + + @Test + fun `getToken() should return null if InternalAuthProvider is not available`() = runTest { + val dataConnectAuth = newDataConnectAuth(deferredInternalAuthProvider = UnavailableDeferred()) + dataConnectAuth.initialize() + advanceUntilIdle() + + val result = dataConnectAuth.getToken(requestId) + + withClue("result=$result") { result.shouldBeNull() } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("returns null") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("token provider is not (yet?) available") + } + + @Test + fun `getToken() should throw if invoked before initialize()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + + val exception = shouldThrow { dataConnectAuth.getToken(requestId) } + + exception shouldHaveMessage "getToken() cannot be called before initialize()" + } + + @Test + fun `getToken() should throw if invoked after close()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.close() + + val exception = shouldThrow { dataConnectAuth.getToken(requestId) } + + exception shouldHaveMessage + "DataConnectCredentialsTokenManager ${dataConnectAuth.instanceId} was closed" + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "throws CredentialsTokenManagerClosedException" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("has been closed") + } + + @Test + fun `getToken() should return null if no user is signed in`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns + Tasks.forException(FirebaseNoSignedInUserException("j8rkghbcnz")) + + val result = dataConnectAuth.getToken(requestId) + + withClue("result=$result") { result.shouldBeNull() } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("returns null") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("FirebaseAuth reports no signed-in user") + } + + @Test + fun `getToken() should return the token returned from FirebaseAuth`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) + + val result = dataConnectAuth.getToken(requestId) + + withClue("result=$result") { result shouldBe accessToken } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "returns retrieved token: ${accessToken.toScrubbedAccessToken()}" + ) + mockLogger.shouldNotHaveLoggedAnyMessagesContaining(accessToken) + } + + @Test + fun `getToken() should return re-throw the exception from the task returned from FirebaseAuth`() = + runTest { + class TestException(message: String) : Exception(message) + + val exception = TestException("xqtbckcn6w") + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns + Tasks.forException(exception) + + val result = dataConnectAuth.runCatching { getToken(requestId) } + + result.asClue { it.exceptionOrNull() shouldBeSameInstanceAs exception } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "getToken() failed unexpectedly", + exception + ) + } + + @Test + fun `getToken() should return re-throw the exception thrown by InternalAuthProvider getAccessToken()`() = + runTest { + class TestException(message: String) : Exception(message) + + val exception = TestException("s4c4xr9z4p") + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers { throw exception } + + val result = dataConnectAuth.runCatching { getToken(requestId) } + + result.asClue { it.exceptionOrNull() shouldBeSameInstanceAs exception } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "getToken() failed unexpectedly", + exception + ) + } + + @Test + fun `getToken() should force refresh the access token after calling forceRefresh()`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) + + dataConnectAuth.forceRefresh() + val result = dataConnectAuth.getToken(requestId) + + withClue("result=$result") { result shouldBe accessToken } + verify(exactly = 1) { mockInternalAuthProvider.getAccessToken(true) } + verify(exactly = 0) { mockInternalAuthProvider.getAccessToken(false) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining(requestId) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("getToken(forceRefresh=true)") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "returns retrieved token: ${accessToken.toScrubbedAccessToken()}" + ) + mockLogger.shouldNotHaveLoggedAnyMessagesContaining(accessToken) + } + + @Test + fun `getToken() should NOT force refresh the access token without calling forceRefresh()`() = + runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) + + dataConnectAuth.getToken(requestId) + + verify(exactly = 1) { mockInternalAuthProvider.getAccessToken(false) } + verify(exactly = 0) { mockInternalAuthProvider.getAccessToken(true) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("getToken(forceRefresh=false)") + } + + @Test + fun `getToken() should NOT force refresh the access token after it is force refreshed`() = + runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) + + dataConnectAuth.forceRefresh() + dataConnectAuth.getToken(requestId) + dataConnectAuth.getToken(requestId) + dataConnectAuth.getToken(requestId) + + verify(exactly = 2) { mockInternalAuthProvider.getAccessToken(false) } + verify(exactly = 1) { mockInternalAuthProvider.getAccessToken(true) } + mockLogger.shouldHaveLoggedAtLeastOneMessageContaining("getToken(forceRefresh=false)") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("getToken(forceRefresh=true)") + } + + @Test + fun `getToken() should ask for a token from FirebaseAuth on every invocation`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + val tokens = CopyOnWriteArrayList() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers + { + taskForToken(accessTokenGenerator.next().also { tokens.add(it) }) + } + + val results = List(5) { dataConnectAuth.getToken(requestId) } + + results shouldContainExactly tokens + } + + @Test + fun `getToken() should conflate concurrent requests`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + val tokens = CopyOnWriteArrayList() + coEvery { mockInternalAuthProvider.getAccessToken(any()) } answers + { + taskForToken(accessTokenGenerator.next().also { tokens.add(it) }) + } + + val latch = SuspendingCountDownLatch(500) + val jobs = + List(latch.count) { + backgroundScope.async(Dispatchers.IO) { + latch.run { + countDown() + await() + } + dataConnectAuth.getToken(requestId) + } + } + + val actualTokens = jobs.map { it.await() } + actualTokens.forEachIndexed { index, token -> + withClue("actualTokens[$index]") { tokens shouldContain token } + } + verify(atMost = 50) { mockInternalAuthProvider.getAccessToken(any()) } + } + + @Test + fun `getToken() should re-fetch token if invalidated concurrently`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + val invocationCount = AtomicInteger(0) + val tokens = CopyOnWriteArrayList().apply { add(accessToken) } + coEvery { mockInternalAuthProvider.getAccessToken(any()) } coAnswers + { + val invocationIndex = invocationCount.getAndIncrement() + if (invocationIndex == 0 || invocationIndex == 1) { + // Simulate a concurrent call to forceRefresh() while + // InternalAuthProvider.getAccessToken() is in-flight. + dataConnectAuth.forceRefresh() + } + val forceRefresh: Boolean = firstArg() + val token = + if (!forceRefresh) { + tokens.last() + } else { + accessTokenGenerator.next().also { tokens.add(it) } + } + taskForToken(token) + } + + val result = dataConnectAuth.getToken(requestId) + + withClue("result=$result") { result shouldBe tokens.last() } + verify(exactly = 2) { mockInternalAuthProvider.getAccessToken(true) } + verify(exactly = 1) { mockInternalAuthProvider.getAccessToken(false) } + mockLogger.shouldHaveLoggedAtLeastOneMessageContaining("retrying due to needs token refresh") + mockLogger.shouldHaveLoggedAtLeastOneMessageContaining("getToken(forceRefresh=true)") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("getToken(forceRefresh=false)") + } + + @Test + fun `getToken() should ignore results with lower sequence number`() = runTest { + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + val invocationCount = AtomicInteger(0) + val tokens = CopyOnWriteArrayList() + val getTokenJob2 = + async(start = CoroutineStart.LAZY) { + val accessToken = dataConnectAuth.getToken(requestId) + accessToken + } + coEvery { mockInternalAuthProvider.getAccessToken(any()) } coAnswers + { + if (invocationCount.getAndIncrement() == 0) { + // Simulate a concurrent call to forceRefresh() while + // InternalAuthProvider.getAccessToken() is in-flight. + getTokenJob2.start() + advanceUntilIdle() + } + val rv = taskForToken(accessTokenGenerator.next().also { tokens.add(it) }) + rv + } + + val result1 = dataConnectAuth.getToken(requestId) + withClue("getTokenJob2.isActive") { getTokenJob2.isActive shouldBe true } + val result2 = getTokenJob2.await() + + withClue("result1=$result1") { result1 shouldBe tokens[0] } + withClue("result2=$result2") { result2 shouldBe tokens[1] } + verify(exactly = 2) { mockInternalAuthProvider.getAccessToken(false) } + verify(exactly = 0) { mockInternalAuthProvider.getAccessToken(true) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("got an old result; retrying") + } + + @Test + fun `DataConnectAuth initializes even if whenAvailable() throws`() = runTest { + class TestException : Exception("z44jcswqxq") + + val testException = TestException() + val deferredInternalAuthProvider: DeferredInternalAuthProvider = mockk { + every { whenAvailable(any()) } throws testException + } + val dataConnectAuth = + newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() + advanceUntilIdle() + + val result = dataConnectAuth.getToken(requestId) + dataConnectAuth.close() + + withClue("result=$result") { result.shouldBeNull() } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("$testException") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("k6rwgqg9gh") + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "${dataConnectAuth.instanceId} whenAvailable" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("token provider is not (yet?) available") + } + + @Test + fun `addIdTokenListener() should NOT be called if whenAvailable() calls back after close()`() = + runTest { + val deferredInternalAuthProvider: DeferredInternalAuthProvider = mockk(relaxed = true) + val dataConnectAuth = + newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() + advanceUntilIdle() + dataConnectAuth.close() + val deferredInternalAuthProviderHandlerSlot = slot>() + verify { + deferredInternalAuthProvider.whenAvailable(capture(deferredInternalAuthProviderHandlerSlot)) + } + + deferredInternalAuthProviderHandlerSlot.captured.handle { mockInternalAuthProvider } + + continually(duration = 500.milliseconds) { + confirmVerified(deferredInternalAuthProvider) + yield() + } + } + + @Test + fun `removeIdTokenListener() should be called if close() is called concurrently during addIdTokenListener()`() = + runTest { + val deferredInternalAuthProvider = DelayedDeferred(mockInternalAuthProvider) + val dataConnectAuth = + newDataConnectAuth(deferredInternalAuthProvider = deferredInternalAuthProvider) + dataConnectAuth.initialize() + advanceUntilIdle() + every { mockInternalAuthProvider.addIdTokenListener(any()) } answers + { + dataConnectAuth.close() + } + deferredInternalAuthProvider.makeAvailable() + val idTokenListenerSlot = slot() + eventually(`check every 100 milliseconds for 2 seconds`) { + verify { mockInternalAuthProvider.addIdTokenListener(capture(idTokenListenerSlot)) } + } + val idTokenListener = idTokenListenerSlot.captured + + eventually(`check every 100 milliseconds for 2 seconds`) { + verify { mockInternalAuthProvider.removeIdTokenListener(idTokenListener) } + } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "unregistering token listener that was just added" + ) + } + + @Test + fun `addIdTokenListener() throwing IllegalStateException due to FirebaseApp deleted should be ignored`() = + runTest { + every { mockInternalAuthProvider.addIdTokenListener(any()) } throws + firebaseAppDeletedException + coEvery { mockInternalAuthProvider.getAccessToken(any()) } returns taskForToken(accessToken) + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + + eventually(`check every 100 milliseconds for 2 seconds`) { + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "ignoring exception: $firebaseAppDeletedException" + ) + } + val result = dataConnectAuth.getToken(requestId) + withClue("result=$result") { result shouldBe accessToken } + } + + @Test + fun `removeIdTokenListener() throwing IllegalStateException due to FirebaseApp deleted should be ignored`() = + runTest { + every { mockInternalAuthProvider.removeIdTokenListener(any()) } throws + firebaseAppDeletedException + val dataConnectAuth = newDataConnectAuth() + dataConnectAuth.initialize() + advanceUntilIdle() + + dataConnectAuth.close() + + eventually(`check every 100 milliseconds for 2 seconds`) { + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "ignoring exception: $firebaseAppDeletedException" + ) + } + } + + private fun TestScope.newDataConnectAuth( + deferredInternalAuthProvider: DeferredInternalAuthProvider = + ImmediateDeferred(mockInternalAuthProvider), + logger: Logger = mockLogger + ) = + DataConnectAuth( + deferredAuthProvider = deferredInternalAuthProvider, + parentCoroutineScope = newBackgroundScopeThatAdvancesLikeForeground(), + blockingDispatcher = + StandardTestDispatcher(testScheduler, name = "4jg7adscn6_DataConnectAuth_TestDispatcher"), + logger = logger + ) + + private companion object { + val `check every 100 milliseconds for 2 seconds` = eventuallyConfig { + duration = 2.seconds + interval = 100.milliseconds + } + + val firebaseAppDeletedException + get() = java.lang.IllegalStateException("FirebaseApp was deleted") + + fun taskForToken(token: String?): Task = + Tasks.forResult(mockk(relaxed = true) { every { getToken() } returns token }) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt new file mode 100644 index 00000000000..4fe6f17d9ec --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt @@ -0,0 +1,686 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectError +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.DataConnectUntypedData +import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult +import com.google.firebase.dataconnect.core.DataConnectGrpcClientGlobals.deserialize +import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.connectorConfig +import com.google.firebase.dataconnect.testutil.dataConnectError +import com.google.firebase.dataconnect.testutil.iterator +import com.google.firebase.dataconnect.testutil.newMockLogger +import com.google.firebase.dataconnect.testutil.operationName +import com.google.firebase.dataconnect.testutil.operationResult +import com.google.firebase.dataconnect.testutil.projectId +import com.google.firebase.dataconnect.testutil.requestId +import com.google.firebase.dataconnect.testutil.shouldHaveLoggedExactlyOneMessageContaining +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toMap +import com.google.protobuf.ListValue +import com.google.protobuf.Value +import google.firebase.dataconnect.proto.ExecuteMutationRequest +import google.firebase.dataconnect.proto.ExecuteMutationResponse +import google.firebase.dataconnect.proto.ExecuteQueryRequest +import google.firebase.dataconnect.proto.ExecuteQueryResponse +import google.firebase.dataconnect.proto.GraphqlError +import google.firebase.dataconnect.proto.SourceLocation +import io.grpc.Status +import io.grpc.StatusException +import io.kotest.assertions.asClue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.egyptianHieroglyphs +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.merge +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.kotest.property.arbs.firstName +import io.kotest.property.arbs.travel.airline +import io.kotest.property.checkAll +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.Serializable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import org.junit.Rule +import org.junit.Test + +class DataConnectGrpcClientUnitTest { + + @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + + private val key = "3sw2m4vkbg" + private val rs = RandomSource.default() + private val projectId = Arb.projectId(key).next(rs) + private val connectorConfig = Arb.connectorConfig(key).next(rs) + private val requestId = Arb.requestId(key).next(rs) + private val operationName = Arb.operationName(key).next(rs) + private val variables = buildStructProto { put("dhxpwjtb6s", key) } + private val callerSdkType = Arb.callerSdkType().next(rs) + + private val mockDataConnectAuth: DataConnectAuth = + mockk(relaxed = true, name = "mockDataConnectAuth-$key") + private val mockDataConnectAppCheck: DataConnectAppCheck = + mockk(relaxed = true, name = "mockDataConnectAppCheck-$key") + + private val mockDataConnectGrpcRPCs: DataConnectGrpcRPCs = + mockk(relaxed = true, name = "mockDataConnectGrpcRPCs-$key") { + coEvery { executeQuery(any(), any(), any()) } returns + ExecuteQueryResponse.getDefaultInstance() + coEvery { executeMutation(any(), any(), any()) } returns + ExecuteMutationResponse.getDefaultInstance() + } + + private val mockLogger = newMockLogger(key) + + private val dataConnectGrpcClient = + DataConnectGrpcClient( + projectId = projectId, + connector = connectorConfig, + grpcRPCs = mockDataConnectGrpcRPCs, + dataConnectAuth = mockDataConnectAuth, + dataConnectAppCheck = mockDataConnectAppCheck, + logger = mockLogger, + ) + + @Test + fun `executeQuery() should send the right request`() = runTest { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + + val expectedName = + "projects/${projectId}" + + "/locations/${connectorConfig.location}" + + "/services/${connectorConfig.serviceId}" + + "/connectors/${connectorConfig.connector}" + val expectedRequest = + ExecuteQueryRequest.newBuilder() + .setName(expectedName) + .setOperationName(operationName) + .setVariables(variables) + .build() + coVerify { mockDataConnectGrpcRPCs.executeQuery(requestId, expectedRequest, callerSdkType) } + } + + @Test + fun `executeMutation() should send the right request`() = runTest { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + + val expectedName = + "projects/${projectId}" + + "/locations/${connectorConfig.location}" + + "/services/${connectorConfig.serviceId}" + + "/connectors/${connectorConfig.connector}" + val expectedRequest = + ExecuteMutationRequest.newBuilder() + .setName(expectedName) + .setOperationName(operationName) + .setVariables(variables) + .build() + coVerify { mockDataConnectGrpcRPCs.executeMutation(requestId, expectedRequest, callerSdkType) } + } + + @Test + fun `executeQuery() should return null data and empty errors if response is empty`() = runTest { + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } returns + ExecuteQueryResponse.getDefaultInstance() + + val operationResult = + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + + operationResult shouldBe OperationResult(data = null, errors = emptyList()) + } + + @Test + fun `executeMutation() should return null data and empty errors if response is empty`() = + runTest { + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } returns + ExecuteMutationResponse.getDefaultInstance() + + val operationResult = + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + + operationResult shouldBe OperationResult(data = null, errors = emptyList()) + } + + @Test + fun `executeQuery() should return data and errors`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val responseErrors = List(3) { GraphqlErrorInfo.random(RandomSource.default()) } + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } returns + ExecuteQueryResponse.newBuilder() + .setData(responseData) + .addAllErrors(responseErrors.map { it.graphqlError }) + .build() + + val operationResult = + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + + operationResult shouldBe + OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + } + + @Test + fun `executeMutation() should return data and errors`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val responseErrors = List(3) { GraphqlErrorInfo.random(RandomSource.default()) } + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } returns + ExecuteMutationResponse.newBuilder() + .setData(responseData) + .addAllErrors(responseErrors.map { it.graphqlError }) + .build() + + val operationResult = + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + + operationResult shouldBe + OperationResult(data = responseData, errors = responseErrors.map { it.dataConnectError }) + } + + @Test + fun `executeQuery() should propagate non-grpc exceptions`() = runTest { + val exception = TestException(key) + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } throws exception + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBe exception + } + + @Test + fun `executeMutation() should propagate non-grpc exceptions`() = runTest { + val exception = TestException(key) + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } throws exception + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBe exception + } + + @Test + fun `executeQuery() should retry with a fresh auth token on UNAUTHENTICATED`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val forceRefresh = AtomicBoolean(false) + coEvery { mockDataConnectAuth.forceRefresh() } answers { forceRefresh.set(true) } + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } answers + { + if (forceRefresh.get()) { + ExecuteQueryResponse.newBuilder().setData(responseData).build() + } else { + // Use a custom description to ensure that DataConnectGrpcClient is checking for just + // the code, and not the entire equality of Status.UNAUTHENTICATED. + throw StatusException( + Status.UNAUTHENTICATED.withDescription( + "this error should be ignored and result in a retry with a fresh token n2ak4cq6jr" + ) + ) + } + } + + val result = + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + + result shouldBe OperationResult(data = responseData, errors = emptyList()) + coVerify(exactly = 2) { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "retrying with fresh Auth and/or AppCheck tokens" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("UNAUTHENTICATED") + } + + @Test + fun `executeMutation() should retry with a fresh auth token on UNAUTHENTICATED`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val forceRefresh = AtomicBoolean(false) + coEvery { mockDataConnectAuth.forceRefresh() } answers { forceRefresh.set(true) } + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } answers + { + if (forceRefresh.get()) { + ExecuteMutationResponse.newBuilder().setData(responseData).build() + } else { + // Use a custom description to ensure that DataConnectGrpcClient is checking for just + // the code, and not the entire equality of Status.UNAUTHENTICATED. + throw StatusException( + Status.UNAUTHENTICATED.withDescription( + "this error should be ignored and result in a retry with a fresh token p3vmc3gs5v" + ) + ) + } + } + + val result = + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + + result shouldBe OperationResult(data = responseData, errors = emptyList()) + coVerify(exactly = 2) { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "retrying with fresh Auth and/or AppCheck tokens" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("UNAUTHENTICATED") + } + + @Test + fun `executeQuery() should retry with a fresh AppCheck token on UNAUTHENTICATED`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val forceRefresh = AtomicBoolean(false) + coEvery { mockDataConnectAppCheck.forceRefresh() } answers { forceRefresh.set(true) } + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } answers + { + if (forceRefresh.get()) { + ExecuteQueryResponse.newBuilder().setData(responseData).build() + } else { + // Use a custom description to ensure that DataConnectGrpcClient is checking for just + // the code, and not the entire equality of Status.UNAUTHENTICATED. + throw StatusException( + Status.UNAUTHENTICATED.withDescription( + "this error should be ignored and result in a retry with a fresh token tepb5xq4kk" + ) + ) + } + } + + val result = + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + + result shouldBe OperationResult(data = responseData, errors = emptyList()) + coVerify(exactly = 2) { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "retrying with fresh Auth and/or AppCheck tokens" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("UNAUTHENTICATED") + } + + @Test + fun `executeMutation() should retry with a fresh AppCheck token on UNAUTHENTICATED`() = runTest { + val responseData = buildStructProto { put("foo", key) } + val forceRefresh = AtomicBoolean(false) + coEvery { mockDataConnectAppCheck.forceRefresh() } answers { forceRefresh.set(true) } + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } answers + { + if (forceRefresh.get()) { + ExecuteMutationResponse.newBuilder().setData(responseData).build() + } else { + // Use a custom description to ensure that DataConnectGrpcClient is checking for just + // the code, and not the entire equality of Status.UNAUTHENTICATED. + throw StatusException( + Status.UNAUTHENTICATED.withDescription( + "this error should be ignored and result in a retry with a fresh token v2449h6ty8" + ) + ) + } + } + + val result = + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + + result shouldBe OperationResult(data = responseData, errors = emptyList()) + coVerify(exactly = 2) { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } + mockLogger.shouldHaveLoggedExactlyOneMessageContaining( + "retrying with fresh Auth and/or AppCheck tokens" + ) + mockLogger.shouldHaveLoggedExactlyOneMessageContaining("UNAUTHENTICATED") + } + + @Test + fun `executeQuery() should NOT retry on error status other than UNAUTHENTICATED`() = runTest { + val exception = StatusException(Status.INTERNAL) + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } throws exception + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception + coVerify(exactly = 0) { mockDataConnectAuth.forceRefresh() } + coVerify(exactly = 0) { mockDataConnectAppCheck.forceRefresh() } + } + + @Test + fun `executeMutation() should NOT retry on error status other than UNAUTHENTICATED`() = runTest { + val exception = StatusException(Status.INTERNAL) + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } throws exception + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception + coVerify(exactly = 0) { mockDataConnectAuth.forceRefresh() } + coVerify(exactly = 0) { mockDataConnectAppCheck.forceRefresh() } + } + + @Test + fun `executeQuery() should throw the exception from the retry if retry also fails with UNAUTHENTICATED`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = StatusException(Status.UNAUTHENTICATED) + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + @Test + fun `executeMutation() should throw the exception from the retry if retry also fails with UNAUTHENTICATED`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = StatusException(Status.UNAUTHENTICATED) + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + @Test + fun `executeQuery() should throw the exception from the retry if retry fails with a code other than UNAUTHENTICATED`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = StatusException(Status.ABORTED) + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + @Test + fun `executeMutation() should throw the exception from the retry if retry fails with a code other than UNAUTHENTICATED`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = StatusException(Status.ABORTED) + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + @Test + fun `executeQuery() should throw the exception from the retry if retry fails with some other exception`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = TestException(key) + coEvery { mockDataConnectGrpcRPCs.executeQuery(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeQuery(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + @Test + fun `executeMutation() should throw the exception from the retry if retry fails with some other exception`() = + runTest { + val exception1 = StatusException(Status.UNAUTHENTICATED) + val exception2 = TestException(key) + coEvery { mockDataConnectGrpcRPCs.executeMutation(any(), any(), any()) } throwsMany + (listOf(exception1, exception2)) + + val thrownException = + shouldThrow { + dataConnectGrpcClient.executeMutation(requestId, operationName, variables, callerSdkType) + } + + thrownException shouldBeSameInstanceAs exception2 + } + + private class TestException(message: String) : Exception(message) + + private data class GraphqlErrorInfo( + val graphqlError: GraphqlError, + val dataConnectError: DataConnectError, + ) { + companion object { + private val randomPathComponents = + Arb.string( + minSize = 1, + maxSize = 8, + codepoints = Codepoint.alphanumeric().merge(Codepoint.egyptianHieroglyphs()), + ) + .iterator(edgeCaseProbability = 0.33f) + + private val randomMessages = + Arb.string(minSize = 1, maxSize = 100).iterator(edgeCaseProbability = 0.33f) + + private val randomInts = Arb.int().iterator(edgeCaseProbability = 0.2f) + + fun random(rs: RandomSource): GraphqlErrorInfo { + + val dataConnectErrorPath = mutableListOf() + val graphqlErrorPath = ListValue.newBuilder() + repeat(6) { + if (rs.random.nextFloat() < 0.33f) { + val pathComponent = randomInts.next(rs) + dataConnectErrorPath.add(DataConnectError.PathSegment.ListIndex(pathComponent)) + graphqlErrorPath.addValues(Value.newBuilder().setNumberValue(pathComponent.toDouble())) + } else { + val pathComponent = randomPathComponents.next(rs) + dataConnectErrorPath.add(DataConnectError.PathSegment.Field(pathComponent)) + graphqlErrorPath.addValues(Value.newBuilder().setStringValue(pathComponent)) + } + } + + val dataConnectErrorLocations = mutableListOf() + val graphqlErrorLocations = mutableListOf() + repeat(3) { + val line = randomInts.next(rs) + val column = randomInts.next(rs) + dataConnectErrorLocations.add( + DataConnectError.SourceLocation(line = line, column = column) + ) + graphqlErrorLocations.add( + SourceLocation.newBuilder().setLine(line).setColumn(column).build() + ) + } + + val message = randomMessages.next(rs) + val graphqlError = + GraphqlError.newBuilder() + .apply { + setMessage(message) + setPath(graphqlErrorPath) + addAllLocations(graphqlErrorLocations) + } + .build() + + val dataConnectError = + DataConnectError( + message = message, + path = dataConnectErrorPath.toList(), + locations = dataConnectErrorLocations.toList() + ) + + return GraphqlErrorInfo(graphqlError, dataConnectError) + } + } + } +} + +@Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_AGAINST_NOT_NOTHING_EXPECTED_TYPE") +class DataConnectGrpcClientOperationResultUnitTest { + + @Test + fun `deserialize() should ignore the module given with DataConnectUntypedData`() { + val errors = listOf(Arb.dataConnectError().next()) + val operationResult = OperationResult(buildStructProto { put("foo", 42.0) }, errors) + val result = operationResult.deserialize(DataConnectUntypedData, mockk()) + result shouldBe DataConnectUntypedData(mapOf("foo" to 42.0), errors) + } + + @Test + fun `deserialize() should treat DataConnectUntypedData specially`() = runTest { + checkAll(iterations = 1000, Arb.operationResult()) { operationResult -> + val result = operationResult.deserialize(DataConnectUntypedData, serializersModule = null) + + result.asClue { + if (operationResult.data === null) { + it.data.shouldBeNull() + } else { + it.data shouldBe operationResult.data.toMap() + } + it.errors shouldContainExactly operationResult.errors + } + } + } + + @Test + fun `deserialize() should throw if one or more errors and data is null`() = runTest { + val arb = Arb.operationResult().filter { it.errors.isNotEmpty() }.map { it.copy(data = null) } + checkAll(iterations = 5, arb) { operationResult -> + val exception = + shouldThrow { + operationResult.deserialize(mockk(), serializersModule = null) + } + exception.message shouldContain "${operationResult.errors}" + } + } + + @Test + fun `deserialize() should throw if one or more errors and data is _not_ null`() = runTest { + val arb = Arb.operationResult().filter { it.data !== null && it.errors.isNotEmpty() } + checkAll(iterations = 5, arb) { operationResult -> + val exception = + shouldThrow { + operationResult.deserialize(mockk(), serializersModule = null) + } + exception.message shouldContain "${operationResult.errors}" + } + } + + @Test + fun `deserialize() should throw if data is null and errors is empty`() { + val operationResult = OperationResult(data = null, errors = emptyList()) + val exception = + shouldThrow { + operationResult.deserialize(mockk(), serializersModule = null) + } + exception.message shouldContain "no data" + } + + @Test + fun `deserialize() should pass through the SerializersModule`() { + val data = encodeToStruct(TestData("4jv7vkrs7a")) + val serializersModule: SerializersModule = mockk() + val operationResult = OperationResult(data = data, errors = emptyList()) + val deserializer: DeserializationStrategy = spyk(serializer()) + + operationResult.deserialize(deserializer, serializersModule) + + val slot = slot() + verify { deserializer.deserialize(capture(slot)) } + slot.captured.serializersModule shouldBeSameInstanceAs serializersModule + } + + @Test + fun `deserialize() successfully deserializes`() = runTest { + val testData = TestData(Arb.firstName().next().name) + val operationResult = OperationResult(encodeToStruct(testData), errors = emptyList()) + + val deserializedData = operationResult.deserialize(serializer(), null) + + deserializedData shouldBe testData + } + + @Test + fun `deserialize() throws if decoding fails`() = runTest { + val data = buildStructProto { put("zzzz", 42) } + val operationResult = OperationResult(data, errors = emptyList()) + shouldThrow { operationResult.deserialize(serializer(), null) } + } + + @Test + fun `deserialize() re-throws DataConnectException`() = runTest { + val data = encodeToStruct(TestData("fe45zhyd3m")) + val operationResult = OperationResult(data = data, errors = emptyList()) + val deserializer: DeserializationStrategy = spyk(serializer()) + val exception = DataConnectException(message = Arb.airline().next().name) + every { deserializer.deserialize(any()) } throws (exception) + + val thrownException = + shouldThrow { operationResult.deserialize(deserializer, null) } + + thrownException shouldBeSameInstanceAs exception + } + + @Test + fun `deserialize() wraps non-DataConnectException in DataConnectException`() = runTest { + val data = encodeToStruct(TestData("rbmkny6b4r")) + val operationResult = OperationResult(data = data, errors = emptyList()) + val deserializer: DeserializationStrategy = spyk(serializer()) + class MyException : Exception("y3cx44q43q") + val exception = MyException() + every { deserializer.deserialize(any()) } throws (exception) + + val thrownException = + shouldThrow { operationResult.deserialize(deserializer, null) } + + thrownException.cause shouldBeSameInstanceAs exception + } + + @Serializable data class TestData(val foo: String) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadataUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadataUnitTest.kt new file mode 100644 index 00000000000..3ae47f85a38 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadataUnitTest.kt @@ -0,0 +1,308 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.core + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.firebase.dataconnect.BuildConfig +import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType +import com.google.firebase.dataconnect.testutil.FirebaseAppUnitTestingRule +import com.google.firebase.dataconnect.testutil.accessToken +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.connectorConfig +import com.google.firebase.dataconnect.testutil.requestId +import io.grpc.Metadata +import io.kotest.assertions.asClue +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DataConnectGrpcMetadataUnitTest { + + @get:Rule + val firebaseAppFactory = + FirebaseAppUnitTestingRule( + appNameKey = "sj4293acqj", + applicationIdKey = "kd8n74kn2j", + projectIdKey = "jhtzhpbtbm" + ) + + @Test + fun `should include x-goog-api-client when callerSdkType is Generated`() = runTest { + val key = "pkprzbns45" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectGrpcMetadata = + testValues.newDataConnectGrpcMetadata( + kotlinVersion = "cdsz85awyc", + androidVersion = 490843892, + dataConnectSdkVersion = "v3q46qc2ax", + grpcVersion = "fq9fhx6j5e", + ) + val requestId = Arb.requestId(key).next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType = CallerSdkType.Generated) + + metadata.asClue { + it.keys() shouldContain "x-goog-api-client" + val metadataKey = Metadata.Key.of("x-goog-api-client", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe + "gl-kotlin/cdsz85awyc gl-android/490843892 fire/v3q46qc2ax grpc/fq9fhx6j5e kotlin/gen" + } + } + + @Test + fun `should include x-goog-api-client when callerSdkType is Base`() = runTest { + val key = "pkprzbns45" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectGrpcMetadata = + testValues.newDataConnectGrpcMetadata( + kotlinVersion = "cdsz85awyc", + androidVersion = 490843892, + dataConnectSdkVersion = "v3q46qc2ax", + grpcVersion = "fq9fhx6j5e", + ) + val requestId = Arb.requestId(key).next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType = CallerSdkType.Base) + + metadata.asClue { + it.keys() shouldContain "x-goog-api-client" + val metadataKey = Metadata.Key.of("x-goog-api-client", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe + "gl-kotlin/cdsz85awyc gl-android/490843892 fire/v3q46qc2ax grpc/fq9fhx6j5e" + } + } + + @Test + fun `should include x-goog-request-params`() = runTest { + val key = "67ns7bkvx8" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val location = testValues.connectorConfig.location + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata() + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { + it.keys() shouldContain "x-goog-request-params" + val metadataKey = Metadata.Key.of("x-goog-request-params", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe "location=${location}&frontend=data" + } + } + + @Test + fun `should include x-firebase-gmpid`() = runTest { + val key = "f835k79x6t" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata(appId = "tvsxjeb745.appId") + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { + it.keys() shouldContain "x-firebase-gmpid" + val metadataKey = Metadata.Key.of("x-firebase-gmpid", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe "tvsxjeb745.appId" + } + } + + @Test + fun `should NOT include x-firebase-gmpid if appId is the empty string`() = runTest { + val key = "fpm5gpgp9z" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata(appId = "") + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { it.keys() shouldNotContain "x-firebase-gmpid" } + } + + @Test + fun `should NOT include x-firebase-gmpid if appId is blank`() = runTest { + val key = "srvvn597dg" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata(appId = " \r\n\t ") + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { it.keys() shouldNotContain "x-firebase-gmpid" } + } + + @Test + fun `should omit x-firebase-auth-token when the auth token is null`() = runTest { + val key = "d85j28zpw9" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + coEvery { testValues.dataConnectAuth.getToken(any()) } returns null + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata() + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { it.keys() shouldNotContain "x-firebase-auth-token" } + } + + @Test + fun `should include x-firebase-auth-token when the auth token is not null`() = runTest { + val key = "d85j28zpw9" + val accessToken = Arb.accessToken(key).next() + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + coEvery { testValues.dataConnectAuth.getToken(any()) } returns accessToken + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata() + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { + it.keys() shouldContain "x-firebase-auth-token" + val metadataKey = Metadata.Key.of("x-firebase-auth-token", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe accessToken + } + } + + @Test + fun `should omit x-firebase-appcheck when the AppCheck token is null`() = runTest { + val key = "jh7km3qgsd" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + coEvery { testValues.dataConnectAppCheck.getToken(any()) } returns null + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata() + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { it.keys() shouldNotContain "x-firebase-appcheck" } + } + + @Test + fun `should include x-firebase-appcheck when the AppCheck token is not null`() = runTest { + val key = "cz6htzv6qk" + val accessToken = Arb.accessToken(key).next() + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + coEvery { testValues.dataConnectAppCheck.getToken(any()) } returns accessToken + val dataConnectGrpcMetadata = testValues.newDataConnectGrpcMetadata() + val requestId = Arb.requestId(key).next() + val callerSdkType = Arb.callerSdkType().next() + + val metadata = dataConnectGrpcMetadata.get(requestId, callerSdkType) + + metadata.asClue { + it.keys() shouldContain "x-firebase-appcheck" + val metadataKey = Metadata.Key.of("x-firebase-appcheck", Metadata.ASCII_STRING_MARSHALLER) + it.get(metadataKey) shouldBe accessToken + } + } + + @Test + fun `forSystemVersions() should return correct values`() = runTest { + val key = "4vjtde6zyv" + val testValues = DataConnectGrpcMetadataTestValues.fromKey(key) + val dataConnectAuth = testValues.dataConnectAuth + val dataConnectAppCheck = testValues.dataConnectAppCheck + val connectorLocation = testValues.connectorConfig.location + + val metadata = + DataConnectGrpcMetadata.forSystemVersions( + firebaseApp = firebaseAppFactory.newInstance(), + dataConnectAuth = dataConnectAuth, + dataConnectAppCheck = dataConnectAppCheck, + connectorLocation = connectorLocation, + parentLogger = mockk(relaxed = true), + ) + + metadata.asClue { + it.dataConnectAuth shouldBeSameInstanceAs dataConnectAuth + it.dataConnectAppCheck shouldBeSameInstanceAs dataConnectAppCheck + it.connectorLocation shouldBeSameInstanceAs connectorLocation + it.kotlinVersion shouldBe "${KotlinVersion.CURRENT}" + it.androidVersion shouldBe Build.VERSION.SDK_INT + it.dataConnectSdkVersion shouldBe BuildConfig.VERSION_NAME + it.grpcVersion shouldBe "" + } + } + + private data class DataConnectGrpcMetadataTestValues( + val dataConnectAuth: DataConnectAuth, + val dataConnectAppCheck: DataConnectAppCheck, + val requestIdSlot: CapturingSlot, + val connectorConfig: ConnectorConfig, + ) { + + fun newDataConnectGrpcMetadata( + kotlinVersion: String = "1.2.3", + androidVersion: Int = 4, + dataConnectSdkVersion: String = "5.6.7", + grpcVersion: String = "8.9.10", + appId: String = "2q5wm7vajh.appId", + ): DataConnectGrpcMetadata = + DataConnectGrpcMetadata( + dataConnectAuth = dataConnectAuth, + dataConnectAppCheck = dataConnectAppCheck, + connectorLocation = connectorConfig.location, + kotlinVersion = kotlinVersion, + androidVersion = androidVersion, + dataConnectSdkVersion = dataConnectSdkVersion, + grpcVersion = grpcVersion, + appId = appId, + parentLogger = mockk(relaxed = true), + ) + + companion object { + fun fromKey( + key: String, + rs: RandomSource = RandomSource.default() + ): DataConnectGrpcMetadataTestValues { + val dataConnectAuth: DataConnectAuth = mockk(relaxed = true) + val dataConnectAppCheck: DataConnectAppCheck = mockk(relaxed = true) + + val accessTokenArb = Arb.accessToken(key) + val requestIdSlot = slot() + coEvery { dataConnectAuth.getToken(capture(requestIdSlot)) } answers + { + accessTokenArb.next(rs) + } + + return DataConnectGrpcMetadataTestValues( + dataConnectAuth = dataConnectAuth, + dataConnectAppCheck = dataConnectAppCheck, + requestIdSlot = requestIdSlot, + connectorConfig = Arb.connectorConfig(key).next(rs), + ) + } + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImplUnitTest.kt new file mode 100644 index 00000000000..f14cf1abbdf --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImplUnitTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.core + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType +import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule +import com.google.firebase.dataconnect.testutil.FirebaseAppUnitTestingRule +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.connectorConfig +import com.google.firebase.dataconnect.testutil.dataConnectSettings +import com.google.firebase.dataconnect.testutil.operationName +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FirebaseDataConnectImplUnitTest { + + @get:Rule val dataConnectLogLevelRule = DataConnectLogLevelRule() + + @get:Rule + val firebaseAppFactory = + FirebaseAppUnitTestingRule( + appNameKey = "b7bf5mmx4x", + applicationIdKey = "gwwftxw9y9", + projectIdKey = "a988y548hz", + ) + + private val rs = RandomSource.default() + private val key = "z89k9qab37" + private val dataConnect: FirebaseDataConnectImpl by lazy { + val app = firebaseAppFactory.newInstance() + + FirebaseDataConnectImpl( + context = app.applicationContext, + app = app, + projectId = app.options.projectId!!, + config = Arb.connectorConfig(key).next(rs), + blockingExecutor = mockk(relaxed = true), + nonBlockingExecutor = mockk(relaxed = true), + deferredAuthProvider = mockk(relaxed = true), + deferredAppCheckProvider = mockk(relaxed = true), + creator = mockk(relaxed = true), + settings = Arb.dataConnectSettings(key).next(rs), + ) + } + + @After + fun closeDataConnect() { + dataConnect.close() + } + + @Test + fun `query() with no options set should use null for each option`() = runTest { + val operationName = Arb.operationName(key).next(rs) + val variables = TestVariables(Arb.string(size = 8).next(rs)) + val dataDeserializer: DeserializationStrategy = mockk() + val variablesSerializer: SerializationStrategy = mockk() + + val queryRef = + dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) + + assertSoftly { + queryRef.operationName shouldBe operationName + queryRef.variables shouldBeSameInstanceAs variables + queryRef.dataDeserializer shouldBeSameInstanceAs dataDeserializer + queryRef.variablesSerializer shouldBeSameInstanceAs variablesSerializer + queryRef.callerSdkType shouldBe CallerSdkType.Base + queryRef.variablesSerializersModule.shouldBeNull() + queryRef.dataSerializersModule.shouldBeNull() + } + } + + @Test + fun `query() with all options specified should use the given options`() = runTest { + val operationName = Arb.operationName(key).next(rs) + val variables = TestVariables(Arb.string(size = 8).next(rs)) + val dataDeserializer: DeserializationStrategy = mockk() + val variablesSerializer: SerializationStrategy = mockk() + val callerSdkType = Arb.callerSdkType().next() + val dataSerializersModule: SerializersModule = mockk() + val variablesSerializersModule: SerializersModule = mockk() + + val queryRef = + dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) { + this.callerSdkType = callerSdkType + this.dataSerializersModule = dataSerializersModule + this.variablesSerializersModule = variablesSerializersModule + } + + assertSoftly { + queryRef.operationName shouldBe operationName + queryRef.variables shouldBeSameInstanceAs variables + queryRef.dataDeserializer shouldBeSameInstanceAs dataDeserializer + queryRef.variablesSerializer shouldBeSameInstanceAs variablesSerializer + queryRef.callerSdkType shouldBe callerSdkType + queryRef.dataSerializersModule shouldBeSameInstanceAs dataSerializersModule + queryRef.variablesSerializersModule shouldBeSameInstanceAs variablesSerializersModule + } + } + + @Test + fun `mutation() with no options set should use null for each option`() = runTest { + val operationName = Arb.operationName(key).next(rs) + val variables = TestVariables(Arb.string(size = 8).next(rs)) + val dataDeserializer: DeserializationStrategy = mockk() + val variablesSerializer: SerializationStrategy = mockk() + + val mutationRef = + dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) + + assertSoftly { + mutationRef.operationName shouldBe operationName + mutationRef.variables shouldBeSameInstanceAs variables + mutationRef.dataDeserializer shouldBeSameInstanceAs dataDeserializer + mutationRef.variablesSerializer shouldBeSameInstanceAs variablesSerializer + mutationRef.callerSdkType shouldBe CallerSdkType.Base + mutationRef.variablesSerializersModule.shouldBeNull() + mutationRef.dataSerializersModule.shouldBeNull() + } + } + + @Test + fun `mutation() with all options specified should use the given options`() = runTest { + val operationName = Arb.operationName(key).next(rs) + val variables = TestVariables(Arb.string(size = 8).next(rs)) + val dataDeserializer: DeserializationStrategy = mockk() + val variablesSerializer: SerializationStrategy = mockk() + val callerSdkType = Arb.callerSdkType().next() + val dataSerializersModule: SerializersModule = mockk() + val variablesSerializersModule: SerializersModule = mockk() + + val mutationRef = + dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + ) { + this.callerSdkType = callerSdkType + this.dataSerializersModule = dataSerializersModule + this.variablesSerializersModule = variablesSerializersModule + } + + assertSoftly { + mutationRef.operationName shouldBe operationName + mutationRef.variables shouldBeSameInstanceAs variables + mutationRef.dataDeserializer shouldBeSameInstanceAs dataDeserializer + mutationRef.variablesSerializer shouldBeSameInstanceAs variablesSerializer + mutationRef.callerSdkType shouldBe callerSdkType + mutationRef.dataSerializersModule shouldBeSameInstanceAs dataSerializersModule + mutationRef.variablesSerializersModule shouldBeSameInstanceAs variablesSerializersModule + } + } + + private data class TestVariables(val foo: String) + + private data class TestData(val bar: String) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt new file mode 100644 index 00000000000..0a89123a808 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt @@ -0,0 +1,523 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.DataConnectException +import com.google.firebase.dataconnect.DataConnectUntypedData +import com.google.firebase.dataconnect.DataConnectUntypedVariables +import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType +import com.google.firebase.dataconnect.core.DataConnectGrpcClient.OperationResult +import com.google.firebase.dataconnect.core.Globals.copy +import com.google.firebase.dataconnect.core.Globals.withDataDeserializer +import com.google.firebase.dataconnect.core.Globals.withVariablesSerializer +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.dataConnectError +import com.google.firebase.dataconnect.testutil.filterNotEqual +import com.google.firebase.dataconnect.testutil.mutationRefImpl +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto +import com.google.firebase.dataconnect.util.SuspendingLazy +import com.google.protobuf.Struct +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.retry +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotBeBlank +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlin.time.Duration +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +@OptIn(ExperimentalCoroutinesApi::class) +class MutationRefImplUnitTest { + + @Serializable private data class TestData(val foo: String) + @Serializable private data class TestVariables(val bar: String) + + @Test + fun `execute() returns the result on success`() = runTest { + val data = Arb.testData().next() + val operationResult = OperationResult(encodeToStruct(data), errors = emptyList()) + val dataConnect = dataConnectWithMutationResult(Result.success(operationResult)) + val mutationRefImpl = Arb.mutationRefImpl().next().copy(dataConnect = dataConnect) + + val mutationResult = mutationRefImpl.execute() + + assertSoftly { + mutationResult.ref shouldBeSameInstanceAs mutationRefImpl + mutationResult.data shouldBe data + } + } + + @Test + fun `execute() calls executeMutation with the correct arguments`() = runTest { + val data = Arb.testData().next() + val operationResult = OperationResult(encodeToStruct(data), errors = emptyList()) + val requestIdSlot: CapturingSlot = slot() + val operationNameSlot: CapturingSlot = slot() + val variablesSlot: CapturingSlot = slot() + val callerSdkTypeSlot: CapturingSlot = slot() + val dataConnect = + dataConnectWithMutationResult( + Result.success(operationResult), + requestIdSlot, + operationNameSlot, + variablesSlot, + callerSdkTypeSlot, + ) + val mutationRefImpl = Arb.mutationRefImpl().next().copy(dataConnect = dataConnect) + + mutationRefImpl.execute() + val requestId1 = requestIdSlot.captured + val operationName1 = operationNameSlot.captured + val variables1 = variablesSlot.captured + val callerSdkType1 = callerSdkTypeSlot.captured + + requestIdSlot.clear() + operationNameSlot.clear() + variablesSlot.clear() + callerSdkTypeSlot.clear() + mutationRefImpl.execute() + val requestId2 = requestIdSlot.captured + val operationName2 = operationNameSlot.captured + val variables2 = variablesSlot.captured + val callerSdkType2 = callerSdkTypeSlot.captured + + assertSoftly { + requestId1.shouldNotBeBlank() + requestId2.shouldNotBeBlank() + requestId1 shouldNotBe requestId2 + operationName1 shouldBe mutationRefImpl.operationName + operationName2 shouldBe operationName1 + variables1 shouldBe encodeToStruct(mutationRefImpl.variables) + variables2 shouldBe variables1 + callerSdkType1 shouldBe mutationRefImpl.callerSdkType + callerSdkType2 shouldBe mutationRefImpl.callerSdkType + } + } + + @Test + fun `execute() handles DataConnectUntypedVariables and DataConnectUntypedData`() = runTest { + val variables = DataConnectUntypedVariables("foo" to 42.0) + val errors = listOf(Arb.dataConnectError().next()) + val data = DataConnectUntypedData(mapOf("bar" to 24.0), errors) + val variablesSlot: CapturingSlot = slot() + val operationResult = OperationResult(buildStructProto { put("bar", 24.0) }, errors) + val dataConnect = + dataConnectWithMutationResult(Result.success(operationResult), variablesSlot = variablesSlot) + val mutationRefImpl = + Arb.mutationRefImpl() + .next() + .copy(dataConnect = dataConnect) + .withVariablesSerializer(variables, DataConnectUntypedVariables) + .withDataDeserializer(DataConnectUntypedData) + + val mutationResult = mutationRefImpl.execute() + + assertSoftly { + mutationResult.ref shouldBeSameInstanceAs mutationRefImpl + mutationResult.data shouldBe data + variablesSlot.captured shouldBe variables.variables.toStructProto() + } + } + + @Test + fun `execute() throws when the data is null`() = runTest { + val operationResult = OperationResult(data = null, errors = emptyList()) + val dataConnect = dataConnectWithMutationResult(Result.success(operationResult)) + val mutationRefImpl = Arb.mutationRefImpl().next().copy(dataConnect = dataConnect) + + shouldThrow { mutationRefImpl.execute() } + } + + @Test + fun `constructor accepts non-null values`() { + val values = Arb.mutationRefImpl().next() + val mutationRefImpl = + MutationRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = values.variablesSerializersModule, + dataSerializersModule = values.dataSerializersModule, + ) + + mutationRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule shouldBeSameInstanceAs values.variablesSerializersModule + it.dataSerializersModule shouldBeSameInstanceAs values.dataSerializersModule + } + } + } + + @Test + fun `constructor accepts null values for nullable parameters`() { + val values = Arb.mutationRefImpl().next() + val mutationRefImpl = + MutationRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = null, + dataSerializersModule = null, + ) + + mutationRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule.shouldBeNull() + it.dataSerializersModule.shouldBeNull() + } + } + } + + @Test + fun `hashCode() should return the same value when invoked repeatedly`() { + val mutationRefImpl: MutationRefImpl<*, *> = Arb.mutationRefImpl().next() + val hashCode = mutationRefImpl.hashCode() + repeat(10) { mutationRefImpl.hashCode() shouldBe hashCode } + } + + @Test + fun `hashCode() should return the same value when invoked on distinct, but equal, objects`() { + val mutationRefImpl1: MutationRefImpl<*, *> = Arb.mutationRefImpl().next() + val mutationRefImpl2: MutationRefImpl<*, *> = mutationRefImpl1.copy() + mutationRefImpl1 shouldNotBeSameInstanceAs mutationRefImpl2 // verify test precondition + repeat(10) { mutationRefImpl1.hashCode() shouldBe mutationRefImpl2.hashCode() } + } + + @Test + fun `hashCode() should incorporate dataConnect`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataConnect = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate operationName`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(operationName = stringArb.next()) } + } + + @Test + fun `hashCode() should incorporate variables`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(variables = TestVariables(stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate dataDeserializer`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataDeserializer = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate variablesSerializer`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializer = mockk(name = stringArb.next())) + } + } + + @Test + fun `hashCode() should incorporate callerSdkType`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(callerSdkType = Arb.callerSdkType().filterNotEqual(it.callerSdkType).next()) + } + } + + @Test + fun `hashCode() should incorporate variablesSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + variablesSerializersModule = + if (it.variablesSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + @Test + fun `hashCode() should incorporate dataSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(dataSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + dataSerializersModule = + if (it.dataSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + private suspend fun verifyHashCodeEventuallyDiffers( + otherFactory: + (other: MutationRefImpl) -> MutationRefImpl + ) { + val obj1: MutationRefImpl = Arb.mutationRefImpl().next() + retry(maxRetry = 50, timeout = Duration.INFINITE) { + val obj2: MutationRefImpl = otherFactory(obj1) + obj1.hashCode() shouldNotBe obj2.hashCode() + } + } + + @Test + fun `equals(this) should return true`() = runTest { + val mutationRefImpl: MutationRefImpl = Arb.mutationRefImpl().next() + mutationRefImpl.equals(mutationRefImpl) shouldBe true + } + + @Test + fun `equals(equal, but distinct, instance) should return true`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2: MutationRefImpl = mutationRefImpl1.copy() + mutationRefImpl1 shouldNotBeSameInstanceAs mutationRefImpl2 // verify test precondition + mutationRefImpl1.equals(mutationRefImpl2) shouldBe true + } + + @Test + fun `equals(null) should return false`() = runTest { + val mutationRefImpl: MutationRefImpl = Arb.mutationRefImpl().next() + mutationRefImpl.equals(null) shouldBe false + } + + @Test + fun `equals(an object of a different type) should return false`() = runTest { + val mutationRefImpl: MutationRefImpl = Arb.mutationRefImpl().next() + mutationRefImpl.equals("not a MutationRefImpl") shouldBe false + } + + @Test + fun `equals() should return false when only dataConnect differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = mutationRefImpl1.copy(dataConnect = mockk(stringArb.next())) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only operationName differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = + mutationRefImpl1.copy(operationName = mutationRefImpl1.operationName + "2") + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variables differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = + mutationRefImpl1.copy(variables = TestVariables(mutationRefImpl1.variables.bar + "2")) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only dataDeserializer differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = mutationRefImpl1.copy(dataDeserializer = mockk(stringArb.next())) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializer differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = mutationRefImpl1.copy(variablesSerializer = mockk(stringArb.next())) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only callerSdkType differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(mutationRefImpl1.callerSdkType).next() + val mutationRefImpl2 = mutationRefImpl1.copy(callerSdkType = callerSdkType2) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializersModule differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = + mutationRefImpl1.copy(variablesSerializersModule = mockk(stringArb.next())) + val mutationRefImplNull = mutationRefImpl1.copy(variablesSerializersModule = null) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + mutationRefImplNull.equals(mutationRefImpl1) shouldBe false + mutationRefImpl1.equals(mutationRefImplNull) shouldBe false + } + + @Test + fun `equals() should return false when only dataSerializersModule differs`() = runTest { + val mutationRefImpl1: MutationRefImpl = Arb.mutationRefImpl().next() + val mutationRefImpl2 = mutationRefImpl1.copy(dataSerializersModule = mockk(stringArb.next())) + val mutationRefImplNull = mutationRefImpl1.copy(dataSerializersModule = null) + mutationRefImpl1.equals(mutationRefImpl2) shouldBe false + mutationRefImplNull.equals(mutationRefImpl1) shouldBe false + mutationRefImpl1.equals(mutationRefImplNull) shouldBe false + } + + @Test + fun `toString() should incorporate the string representations of public properties`() = runTest { + val mutationRefImpl: MutationRefImpl = Arb.mutationRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(mutationRefImpl.callerSdkType).next() + val mutationRefImpls = + listOf( + mutationRefImpl, + mutationRefImpl.copy(callerSdkType = callerSdkType2), + mutationRefImpl.copy(dataSerializersModule = null), + mutationRefImpl.copy(variablesSerializersModule = null), + ) + val toStringResult = mutationRefImpl.toString() + + assertSoftly { + mutationRefImpls.forEach { + it.asClue { + toStringResult.shouldContain("dataConnect=${mutationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${mutationRefImpl.operationName}") + toStringResult.shouldContain("variables=${mutationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${mutationRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${mutationRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${mutationRefImpl.callerSdkType}") + toStringResult.shouldContain( + "dataSerializersModule=${mutationRefImpl.dataSerializersModule}" + ) + toStringResult.shouldContain( + "variablesSerializersModule=${mutationRefImpl.variablesSerializersModule}" + ) + } + } + } + } + + @Test + fun `toString() should include null when dataSerializersModule is null`() = runTest { + val mutationRefImpl: MutationRefImpl = + Arb.mutationRefImpl().next().copy(dataSerializersModule = null) + val toStringResult = mutationRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${mutationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${mutationRefImpl.operationName}") + toStringResult.shouldContain("variables=${mutationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${mutationRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${mutationRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${mutationRefImpl.callerSdkType}") + toStringResult.shouldContain("dataSerializersModule=null") + toStringResult.shouldContain( + "variablesSerializersModule=${mutationRefImpl.variablesSerializersModule}" + ) + } + } + + @Test + fun `toString() should include null when variablesSerializersModule is null`() = runTest { + val mutationRefImpl: MutationRefImpl = + Arb.mutationRefImpl().next().copy(variablesSerializersModule = null) + val toStringResult = mutationRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${mutationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${mutationRefImpl.operationName}") + toStringResult.shouldContain("variables=${mutationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${mutationRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${mutationRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${mutationRefImpl.callerSdkType}") + toStringResult.shouldContain("dataSerializersModule=${mutationRefImpl.dataSerializersModule}") + toStringResult.shouldContain("variablesSerializersModule=null") + } + } + + private companion object { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + + fun Arb.Companion.testVariables(): Arb = arbitrary { + val stringArb = Arb.string(6, Codepoint.alphanumeric()) + TestVariables(stringArb.bind()) + } + + fun Arb.Companion.testData(): Arb = arbitrary { + val stringArb = Arb.string(6, Codepoint.alphanumeric()) + TestData(stringArb.bind()) + } + + fun Arb.Companion.mutationRefImpl(): Arb> = + mutationRefImpl(Arb.testVariables()).map { + it.copy( + variablesSerializer = serializer(), + dataDeserializer = serializer() + ) + } + + fun TestScope.dataConnectWithMutationResult( + result: Result, + requestIdSlot: CapturingSlot = slot(), + operationNameSlot: CapturingSlot = slot(), + variablesSlot: CapturingSlot = slot(), + callerSdkTypeSlot: CapturingSlot = slot(), + ): FirebaseDataConnectInternal = + mockk(relaxed = true) { + every { blockingDispatcher } returns UnconfinedTestDispatcher(testScheduler) + every { lazyGrpcClient } returns + SuspendingLazy { + mockk { + coEvery { + executeMutation( + capture(requestIdSlot), + capture(operationNameSlot), + capture(variablesSlot), + capture(callerSdkTypeSlot), + ) + } returns result.getOrThrow() + } + } + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationResultUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationResultUnitTest.kt new file mode 100644 index 00000000000..af14bc175ad --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationResultUnitTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import io.kotest.property.Arb +import io.kotest.property.arbitrary.next +import io.mockk.mockk +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +class MutationResultImplUnitTest { + + private val mockFirebaseDataConnectInternal: FirebaseDataConnectInternal = mockk() + private val mockDataDeserializer: DeserializationStrategy = mockk() + private val mockVariablesSerializer: SerializationStrategy = mockk() + private val mockSerializersModule: SerializersModule = mockk() + + private val sampleMutation = + MutationRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleMutationOperationName", + variables = TestVariables("sampleMutationTestData"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + private val sampleMutation1 = + MutationRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleMutationOperationName1", + variables = TestVariables("sampleMutationTestData1"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + private val sampleMutation2 = + MutationRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleMutationOperationName2", + variables = TestVariables("sampleMutationTestData2"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + @Test + fun `'data' should be the same object given to the constructor`() { + val data = TestData() + val mutationResult = sampleMutation.MutationResultImpl(data) + + assertThat(mutationResult.data).isSameInstanceAs(data) + } + + @Test + fun `'ref' should be the MutationRefImpl object that was used to create it`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.ref).isSameInstanceAs(sampleMutation) + } + + @Test + fun `toString() should begin with the class name and contain text in parentheses`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.toString()).startsWith("MutationResultImpl(") + assertThat(mutationResult.toString()).endsWith(")") + } + + @Test + fun `toString() should incorporate 'data'`() { + val data = TestData() + val mutationResult = sampleMutation.MutationResultImpl(data) + + assertThat(mutationResult.toString()).containsWithNonAdjacentText("data=$data") + } + + @Test + fun `toString() should incorporate 'ref'`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.toString()).containsWithNonAdjacentText("ref=$sampleMutation") + } + + @Test + fun `equals() should return true for the exact same instance`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.equals(mutationResult)).isTrue() + } + + @Test + fun `equals() should return true for an equal instance`() { + val mutationResult1 = sampleMutation.MutationResultImpl(TestData()) + val mutationResult2 = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult1.equals(mutationResult2)).isTrue() + } + + @Test + fun `equals() should return true if all properties are equal, and 'data' is null`() { + val mutationResult1 = sampleMutation.MutationResultImpl(null) + val mutationResult2 = sampleMutation.MutationResultImpl(null) + + assertThat(mutationResult1.equals(mutationResult2)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult.equals(listOf("foo"))).isFalse() + } + + @Test + fun `equals() should return false when only 'data' differs`() { + val mutationResult1 = sampleMutation.MutationResultImpl(TestData("foo")) + val mutationResult2 = sampleMutation.MutationResultImpl(TestData("bar")) + + assertThat(mutationResult1.equals(mutationResult2)).isFalse() + } + + @Test + fun `equals() should return false when only 'ref' differs`() { + val mutationResult1 = sampleMutation1.MutationResultImpl(TestData()) + val mutationResult2 = sampleMutation2.MutationResultImpl(TestData()) + + assertThat(mutationResult1.equals(mutationResult2)).isFalse() + } + + @Test + fun `equals() should return false when data of first object is null and second is non-null`() { + val mutationResult1 = sampleMutation.MutationResultImpl(null) + val mutationResult2 = sampleMutation.MutationResultImpl(TestData("bar")) + + assertThat(mutationResult1.equals(mutationResult2)).isFalse() + } + + @Test + fun `equals() should return false when data of second object is null and first is non-null`() { + val mutationResult1 = sampleMutation.MutationResultImpl(TestData("bar")) + val mutationResult2 = sampleMutation.MutationResultImpl(null) + + assertThat(mutationResult1.equals(mutationResult2)).isFalse() + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() { + val mutationResult = sampleMutation.MutationResultImpl(TestData()) + + val hashCode = mutationResult.hashCode() + + assertThat(mutationResult.hashCode()).isEqualTo(hashCode) + assertThat(mutationResult.hashCode()).isEqualTo(hashCode) + assertThat(mutationResult.hashCode()).isEqualTo(hashCode) + } + + @Test + fun `hashCode() should return the same value on equal objects`() { + val mutationResult1 = sampleMutation.MutationResultImpl(TestData()) + val mutationResult2 = sampleMutation.MutationResultImpl(TestData()) + + assertThat(mutationResult1.hashCode()).isEqualTo(mutationResult2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if 'data' is different`() { + val mutationResult1 = sampleMutation.MutationResultImpl(TestData("foo")) + val mutationResult2 = sampleMutation.MutationResultImpl(TestData("bar")) + + assertThat(mutationResult1.hashCode()).isNotEqualTo(mutationResult2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if 'ref' is different`() { + val mutationResult1 = sampleMutation1.MutationResultImpl(TestData()) + val mutationResult2 = sampleMutation2.MutationResultImpl(TestData()) + + assertThat(mutationResult1.hashCode()).isNotEqualTo(mutationResult2.hashCode()) + } + + data class TestVariables(val value: String = "TestVariablesDefaultValue") + + data class TestData(val value: String = "TestDataDefaultValue") +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/OperationRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/OperationRefImplUnitTest.kt new file mode 100644 index 00000000000..a7f9f2ed784 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/OperationRefImplUnitTest.kt @@ -0,0 +1,377 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.testutil.StubOperationRefImpl +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.copy +import com.google.firebase.dataconnect.testutil.filterNotEqual +import com.google.firebase.dataconnect.testutil.operationRefImpl +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.retry +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.mockk.mockk +import kotlin.time.Duration +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +class OperationRefImplUnitTest { + + private class TestData + private class TestVariables(val bar: String) + + @Test + fun `constructor accepts non-null values`() { + val values = Arb.operationRefImpl().next() + val operationRefImpl = + object : + OperationRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = values.variablesSerializersModule, + dataSerializersModule = values.dataSerializersModule, + ) { + override suspend fun execute() = TODO() + } + + operationRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule shouldBeSameInstanceAs values.variablesSerializersModule + it.dataSerializersModule shouldBeSameInstanceAs values.dataSerializersModule + } + } + } + + @Test + fun `constructor accepts null values for nullable parameters`() { + val values = Arb.operationRefImpl().next() + val operationRefImpl = + object : + OperationRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = null, + dataSerializersModule = null, + ) { + override suspend fun execute() = TODO() + } + operationRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule.shouldBeNull() + it.dataSerializersModule.shouldBeNull() + } + } + } + + @Test + fun `hashCode() should return the same value when invoked repeatedly`() { + val operationRefImpl: OperationRefImpl<*, *> = Arb.operationRefImpl().next() + val hashCode = operationRefImpl.hashCode() + repeat(10) { operationRefImpl.hashCode() shouldBe hashCode } + } + + @Test + fun `hashCode() should return the same value when invoked on distinct, but equal, objects`() { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy() + operationRefImpl1 shouldNotBeSameInstanceAs operationRefImpl2 // verify test precondition + repeat(10) { operationRefImpl1.hashCode() shouldBe operationRefImpl2.hashCode() } + } + + @Test + fun `hashCode() should incorporate dataConnect`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataConnect = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate operationName`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(operationName = stringArb.next()) } + } + + @Test + fun `hashCode() should incorporate variables`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(variables = TestVariables(stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate dataDeserializer`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataDeserializer = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate variablesSerializer`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializer = mockk(name = stringArb.next())) + } + } + + @Test + fun `hashCode() should incorporate callerSdkType`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(callerSdkType = Arb.callerSdkType().filterNotEqual(it.callerSdkType).next()) + } + } + + @Test + fun `hashCode() should incorporate variablesSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + variablesSerializersModule = + if (it.variablesSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + @Test + fun `hashCode() should incorporate dataSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(dataSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + dataSerializersModule = + if (it.dataSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + private suspend fun verifyHashCodeEventuallyDiffers( + otherFactory: + (other: StubOperationRefImpl) -> StubOperationRefImpl< + TestData, TestVariables + > + ) { + val obj1: StubOperationRefImpl = Arb.operationRefImpl().next() + retry(maxRetry = 50, timeout = Duration.INFINITE) { + val obj2 = otherFactory(obj1) + obj1.hashCode() shouldNotBe obj2.hashCode() + } + } + + @Test + fun `equals(this) should return true`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next() + operationRefImpl.equals(operationRefImpl) shouldBe true + } + + @Test + fun `equals(equal, but distinct, instance) should return true`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy() + operationRefImpl1 shouldNotBeSameInstanceAs operationRefImpl2 // verify test precondition + operationRefImpl1.equals(operationRefImpl2) shouldBe true + } + + @Test + fun `equals(null) should return false`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next() + operationRefImpl.equals(null) shouldBe false + } + + @Test + fun `equals(an object of a different type) should return false`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next() + operationRefImpl.equals("not an OperationRefImpl") shouldBe false + } + + @Test + fun `equals() should return false when only dataConnect differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy(dataConnect = mockk(stringArb.next())) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only operationName differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = + operationRefImpl1.copy(operationName = operationRefImpl1.operationName + "2") + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variables differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = + operationRefImpl1.copy(variables = TestVariables(operationRefImpl1.variables.bar + "2")) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only dataDeserializer differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy(dataDeserializer = mockk(stringArb.next())) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializer differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy(variablesSerializer = mockk(stringArb.next())) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only callerSdkType differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(operationRefImpl1.callerSdkType).next() + val operationRefImpl2 = operationRefImpl1.copy(callerSdkType = callerSdkType2) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializersModule differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = + operationRefImpl1.copy(variablesSerializersModule = mockk(stringArb.next())) + val operationRefImplNull = operationRefImpl1.copy(variablesSerializersModule = null) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + operationRefImplNull.equals(operationRefImpl1) shouldBe false + operationRefImpl1.equals(operationRefImplNull) shouldBe false + } + + @Test + fun `equals() should return false when only dataSerializersModule differs`() = runTest { + val operationRefImpl1 = Arb.operationRefImpl().next() + val operationRefImpl2 = operationRefImpl1.copy(dataSerializersModule = mockk(stringArb.next())) + val operationRefImplNull = operationRefImpl1.copy(dataSerializersModule = null) + operationRefImpl1.equals(operationRefImpl2) shouldBe false + operationRefImplNull.equals(operationRefImpl1) shouldBe false + operationRefImpl1.equals(operationRefImplNull) shouldBe false + } + + @Test + fun `toString() should incorporate the string representations of public properties`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(operationRefImpl.callerSdkType).next() + val operationRefImpls = + listOf( + operationRefImpl, + operationRefImpl.copy(callerSdkType = callerSdkType2), + operationRefImpl.copy(dataSerializersModule = null), + operationRefImpl.copy(variablesSerializersModule = null), + ) + val toStringResult = operationRefImpl.toString() + + assertSoftly { + operationRefImpls.forEach { + it.asClue { + toStringResult.shouldContain("dataConnect=${operationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${operationRefImpl.operationName}") + toStringResult.shouldContain("variables=${operationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${operationRefImpl.dataDeserializer}") + toStringResult.shouldContain( + "variablesSerializer=${operationRefImpl.variablesSerializer}" + ) + toStringResult.shouldContain("callerSdkType=${operationRefImpl.callerSdkType}") + toStringResult.shouldContain( + "dataSerializersModule=${operationRefImpl.dataSerializersModule}" + ) + toStringResult.shouldContain( + "variablesSerializersModule=${operationRefImpl.variablesSerializersModule}" + ) + } + } + } + } + + @Test + fun `toString() should include null when dataSerializersModule is null`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next().copy(dataSerializersModule = null) + val toStringResult = operationRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${operationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${operationRefImpl.operationName}") + toStringResult.shouldContain("variables=${operationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${operationRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${operationRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${operationRefImpl.callerSdkType}") + toStringResult.shouldContain("dataSerializersModule=null") + toStringResult.shouldContain( + "variablesSerializersModule=${operationRefImpl.variablesSerializersModule}" + ) + } + } + + @Test + fun `toString() should include null when variablesSerializersModule is null`() = runTest { + val operationRefImpl = Arb.operationRefImpl().next().copy(variablesSerializersModule = null) + val toStringResult = operationRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${operationRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${operationRefImpl.operationName}") + toStringResult.shouldContain("variables=${operationRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${operationRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${operationRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${operationRefImpl.callerSdkType}") + toStringResult.shouldContain( + "dataSerializersModule=${operationRefImpl.dataSerializersModule}" + ) + toStringResult.shouldContain("variablesSerializersModule=null") + } + } + + private companion object { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + + fun Arb.Companion.testVariables(): Arb = arbitrary { + val stringArb = Arb.string(6, Codepoint.alphanumeric()) + TestVariables(stringArb.bind()) + } + + fun Arb.Companion.operationRefImpl(): Arb> = + operationRefImpl(Arb.testVariables()) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt new file mode 100644 index 00000000000..e6695163947 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt @@ -0,0 +1,436 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.firebase.dataconnect.core.Globals.copy +import com.google.firebase.dataconnect.querymgr.QueryManager +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.filterNotEqual +import com.google.firebase.dataconnect.testutil.queryRefImpl +import com.google.firebase.dataconnect.util.SequencedReference +import com.google.firebase.dataconnect.util.SuspendingLazy +import io.kotest.assertions.asClue +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.retry +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeSameInstanceAs +import io.kotest.matchers.types.shouldNotBeSameInstanceAs +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlin.time.Duration +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +class QueryRefImplUnitTest { + + private class TestData(val foo: String) + private class TestVariables(val bar: String) + + @Test + fun `execute() returns the result on success`() = runTest { + val data = TestData("gy54w6f5be") + val querySlot = slot>() + val dataConnect = dataConnectWithQueryResult(Result.success(data), querySlot) + val queryRefImpl = Arb.queryRefImpl().next().copy(dataConnect = dataConnect) + + val queryResult = queryRefImpl.execute() + + assertSoftly { + queryResult.ref shouldBeSameInstanceAs queryRefImpl + queryResult.data shouldBe data + querySlot.captured shouldBeSameInstanceAs queryRefImpl + } + } + + @Test + fun `execute() throws on failure`() = runTest { + val exception = Exception("forced exception h4sab92yy8") + val querySlot = slot>() + val dataConnect = dataConnectWithQueryResult(Result.failure(exception), querySlot) + val queryRefImpl = Arb.queryRefImpl().next().copy(dataConnect = dataConnect) + + val thrownException = shouldThrow { queryRefImpl.execute() } + + assertSoftly { + thrownException shouldBeSameInstanceAs exception + querySlot.captured shouldBeSameInstanceAs queryRefImpl + } + } + + @Test + fun `subscribe() should return a QuerySubscription`() = runTest { + val queryRefImpl = Arb.queryRefImpl().next() + + val querySubscription = queryRefImpl.subscribe() + + querySubscription.query shouldBeSameInstanceAs queryRefImpl + } + + @Test + fun `subscribe() should always return a new object`() = runTest { + val queryRefImpl = Arb.queryRefImpl().next() + + val querySubscription1 = queryRefImpl.subscribe() + val querySubscription2 = queryRefImpl.subscribe() + + querySubscription1 shouldNotBeSameInstanceAs querySubscription2 + } + + @Test + fun `constructor accepts non-null values`() { + val values = Arb.queryRefImpl().next() + val queryRefImpl = + QueryRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = values.variablesSerializersModule, + dataSerializersModule = values.dataSerializersModule, + ) + + queryRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule shouldBeSameInstanceAs values.variablesSerializersModule + it.dataSerializersModule shouldBeSameInstanceAs values.dataSerializersModule + } + } + } + + @Test + fun `constructor accepts null values for nullable parameters`() { + val values = Arb.queryRefImpl().next() + val queryRefImpl = + QueryRefImpl( + dataConnect = values.dataConnect, + operationName = values.operationName, + variables = values.variables, + dataDeserializer = values.dataDeserializer, + variablesSerializer = values.variablesSerializer, + callerSdkType = values.callerSdkType, + variablesSerializersModule = null, + dataSerializersModule = null, + ) + + queryRefImpl.asClue { + assertSoftly { + it.dataConnect shouldBeSameInstanceAs values.dataConnect + it.operationName shouldBeSameInstanceAs values.operationName + it.variables shouldBeSameInstanceAs values.variables + it.dataDeserializer shouldBeSameInstanceAs values.dataDeserializer + it.variablesSerializer shouldBeSameInstanceAs values.variablesSerializer + it.callerSdkType shouldBe values.callerSdkType + it.variablesSerializersModule.shouldBeNull() + it.dataSerializersModule.shouldBeNull() + } + } + } + + @Test + fun `hashCode() should return the same value when invoked repeatedly`() { + val queryRefImpl: QueryRefImpl<*, *> = Arb.queryRefImpl().next() + val hashCode = queryRefImpl.hashCode() + repeat(10) { queryRefImpl.hashCode() shouldBe hashCode } + } + + @Test + fun `hashCode() should return the same value when invoked on distinct, but equal, objects`() { + val queryRefImpl1: QueryRefImpl<*, *> = Arb.queryRefImpl().next() + val queryRefImpl2: QueryRefImpl<*, *> = queryRefImpl1.copy() + queryRefImpl1 shouldNotBeSameInstanceAs queryRefImpl2 // verify test precondition + repeat(10) { queryRefImpl1.hashCode() shouldBe queryRefImpl2.hashCode() } + } + + @Test + fun `hashCode() should incorporate dataConnect`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataConnect = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate operationName`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(operationName = stringArb.next()) } + } + + @Test + fun `hashCode() should incorporate variables`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(variables = TestVariables(stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate dataDeserializer`() = runTest { + verifyHashCodeEventuallyDiffers { it.copy(dataDeserializer = mockk(name = stringArb.next())) } + } + + @Test + fun `hashCode() should incorporate variablesSerializer`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializer = mockk(name = stringArb.next())) + } + } + + @Test + fun `hashCode() should incorporate callerSdkType`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(callerSdkType = Arb.callerSdkType().filterNotEqual(it.callerSdkType).next()) + } + } + + @Test + fun `hashCode() should incorporate variablesSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(variablesSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + variablesSerializersModule = + if (it.variablesSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + @Test + fun `hashCode() should incorporate dataSerializersModule`() = runTest { + verifyHashCodeEventuallyDiffers { + it.copy(dataSerializersModule = mockk(name = stringArb.next())) + } + verifyHashCodeEventuallyDiffers { + it.copy( + dataSerializersModule = + if (it.dataSerializersModule === null) mockk(name = stringArb.next()) else null + ) + } + } + + private suspend fun verifyHashCodeEventuallyDiffers( + otherFactory: + (other: QueryRefImpl) -> QueryRefImpl + ) { + val obj1: QueryRefImpl = Arb.queryRefImpl().next() + retry(maxRetry = 50, timeout = Duration.INFINITE) { + val obj2: QueryRefImpl = otherFactory(obj1) + obj1.hashCode() shouldNotBe obj2.hashCode() + } + } + + @Test + fun `equals(this) should return true`() = runTest { + val queryRefImpl: QueryRefImpl = Arb.queryRefImpl().next() + queryRefImpl.equals(queryRefImpl) shouldBe true + } + + @Test + fun `equals(equal, but distinct, instance) should return true`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2: QueryRefImpl = queryRefImpl1.copy() + queryRefImpl1 shouldNotBeSameInstanceAs queryRefImpl2 // verify test precondition + queryRefImpl1.equals(queryRefImpl2) shouldBe true + } + + @Test + fun `equals(null) should return false`() = runTest { + val queryRefImpl: QueryRefImpl = Arb.queryRefImpl().next() + queryRefImpl.equals(null) shouldBe false + } + + @Test + fun `equals(an object of a different type) should return false`() = runTest { + val queryRefImpl: QueryRefImpl = Arb.queryRefImpl().next() + queryRefImpl.equals("not a QueryRefImpl") shouldBe false + } + + @Test + fun `equals() should return false when only dataConnect differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(dataConnect = mockk(stringArb.next())) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only operationName differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(operationName = queryRefImpl1.operationName + "2") + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variables differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = + queryRefImpl1.copy(variables = TestVariables(queryRefImpl1.variables.bar + "2")) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only dataDeserializer differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(dataDeserializer = mockk(stringArb.next())) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializer differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(variablesSerializer = mockk(stringArb.next())) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only callerSdkType differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(queryRefImpl1.callerSdkType).next() + val queryRefImpl2 = queryRefImpl1.copy(callerSdkType = callerSdkType2) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + } + + @Test + fun `equals() should return false when only variablesSerializersModule differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(variablesSerializersModule = mockk(stringArb.next())) + val queryRefImplNull = queryRefImpl1.copy(variablesSerializersModule = null) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + queryRefImplNull.equals(queryRefImpl1) shouldBe false + queryRefImpl1.equals(queryRefImplNull) shouldBe false + } + + @Test + fun `equals() should return false when only dataSerializersModule differs`() = runTest { + val queryRefImpl1: QueryRefImpl = Arb.queryRefImpl().next() + val queryRefImpl2 = queryRefImpl1.copy(dataSerializersModule = mockk(stringArb.next())) + val queryRefImplNull = queryRefImpl1.copy(dataSerializersModule = null) + queryRefImpl1.equals(queryRefImpl2) shouldBe false + queryRefImplNull.equals(queryRefImpl1) shouldBe false + queryRefImpl1.equals(queryRefImplNull) shouldBe false + } + + @Test + fun `toString() should incorporate the string representations of public properties`() = runTest { + val queryRefImpl: QueryRefImpl = Arb.queryRefImpl().next() + val callerSdkType2 = Arb.callerSdkType().filterNotEqual(queryRefImpl.callerSdkType).next() + val queryRefImpls = + listOf( + queryRefImpl, + queryRefImpl.copy(callerSdkType = callerSdkType2), + queryRefImpl.copy(dataSerializersModule = null), + queryRefImpl.copy(variablesSerializersModule = null), + ) + val toStringResult = queryRefImpl.toString() + + assertSoftly { + queryRefImpls.forEach { + it.asClue { + toStringResult.shouldContain("dataConnect=${queryRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${queryRefImpl.operationName}") + toStringResult.shouldContain("variables=${queryRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${queryRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${queryRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${queryRefImpl.callerSdkType}") + toStringResult.shouldContain( + "dataSerializersModule=${queryRefImpl.dataSerializersModule}" + ) + toStringResult.shouldContain( + "variablesSerializersModule=${queryRefImpl.variablesSerializersModule}" + ) + } + } + } + } + + @Test + fun `toString() should include null when dataSerializersModule is null`() = runTest { + val queryRefImpl: QueryRefImpl = + Arb.queryRefImpl().next().copy(dataSerializersModule = null) + val toStringResult = queryRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${queryRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${queryRefImpl.operationName}") + toStringResult.shouldContain("variables=${queryRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${queryRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${queryRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${queryRefImpl.callerSdkType}") + toStringResult.shouldContain("dataSerializersModule=null") + toStringResult.shouldContain( + "variablesSerializersModule=${queryRefImpl.variablesSerializersModule}" + ) + } + } + + @Test + fun `toString() should include null when variablesSerializersModule is null`() = runTest { + val queryRefImpl: QueryRefImpl = + Arb.queryRefImpl().next().copy(variablesSerializersModule = null) + val toStringResult = queryRefImpl.toString() + + assertSoftly { + toStringResult.shouldContain("dataConnect=${queryRefImpl.dataConnect}") + toStringResult.shouldContain("operationName=${queryRefImpl.operationName}") + toStringResult.shouldContain("variables=${queryRefImpl.variables}") + toStringResult.shouldContain("dataDeserializer=${queryRefImpl.dataDeserializer}") + toStringResult.shouldContain("variablesSerializer=${queryRefImpl.variablesSerializer}") + toStringResult.shouldContain("callerSdkType=${queryRefImpl.callerSdkType}") + toStringResult.shouldContain("dataSerializersModule=${queryRefImpl.dataSerializersModule}") + toStringResult.shouldContain("variablesSerializersModule=null") + } + } + + private companion object { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + + fun Arb.Companion.testVariables(): Arb = arbitrary { + val stringArb = Arb.string(6, Codepoint.alphanumeric()) + TestVariables(stringArb.bind()) + } + + fun Arb.Companion.queryRefImpl(): Arb> = + queryRefImpl(Arb.testVariables()) + + fun dataConnectWithQueryResult( + result: Result, + querySlot: CapturingSlot> + ): FirebaseDataConnectInternal = + mockk(relaxed = true) { + every { lazyQueryManager } returns + SuspendingLazy { + mockk { + coEvery { execute(capture(querySlot)) } returns SequencedReference(123, result) + } + } + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryResultUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryResultUnitTest.kt new file mode 100644 index 00000000000..7696b23560a --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryResultUnitTest.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.core + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.dataconnect.testutil.callerSdkType +import com.google.firebase.dataconnect.testutil.containsWithNonAdjacentText +import io.kotest.property.Arb +import io.kotest.property.arbitrary.next +import io.mockk.mockk +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule +import org.junit.Test + +@Suppress("ReplaceCallWithBinaryOperator") +class QueryResultImplUnitTest { + + private val mockFirebaseDataConnectInternal = mockk() + private val mockDataDeserializer = mockk>() + private val mockVariablesSerializer = mockk>() + private val mockSerializersModule: SerializersModule = mockk() + + private val sampleQuery = + QueryRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleQueryOperationName", + variables = TestVariables("sampleQueryTestData"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + private val sampleQuery1 = + QueryRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleQueryOperationName1", + variables = TestVariables("sampleQueryTestData1"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + private val sampleQuery2 = + QueryRefImpl( + dataConnect = mockFirebaseDataConnectInternal, + operationName = "sampleQueryOperationName2", + variables = TestVariables("sampleQueryTestData2"), + dataDeserializer = mockDataDeserializer, + variablesSerializer = mockVariablesSerializer, + callerSdkType = Arb.callerSdkType().next(), + dataSerializersModule = mockSerializersModule, + variablesSerializersModule = mockSerializersModule, + ) + + @Test + fun `'data' should be the same object given to the constructor`() { + val data = TestData() + val queryResult = sampleQuery.QueryResultImpl(data) + + assertThat(queryResult.data).isSameInstanceAs(data) + } + + @Test + fun `'ref' should be the QueryRefImpl object that was used to create it`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.ref).isSameInstanceAs(sampleQuery) + } + + @Test + fun `toString() should begin with the class name and contain text in parentheses`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.toString()).startsWith("QueryResultImpl(") + assertThat(queryResult.toString()).endsWith(")") + } + + @Test + fun `toString() should incorporate 'data'`() { + val data = TestData() + val queryResult = sampleQuery.QueryResultImpl(data) + + assertThat(queryResult.toString()).containsWithNonAdjacentText("data=$data") + } + + @Test + fun `toString() should incorporate 'ref'`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.toString()).containsWithNonAdjacentText("ref=$sampleQuery") + } + + @Test + fun `equals() should return true for the exact same instance`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.equals(queryResult)).isTrue() + } + + @Test + fun `equals() should return true for an equal instance`() { + val queryResult1 = sampleQuery.QueryResultImpl(TestData()) + val queryResult2 = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult1.equals(queryResult2)).isTrue() + } + + @Test + fun `equals() should return true if all properties are equal, and 'data' is null`() { + val queryResult1 = sampleQuery.QueryResultImpl(null) + val queryResult2 = sampleQuery.QueryResultImpl(null) + + assertThat(queryResult1.equals(queryResult2)).isTrue() + } + + @Test + fun `equals() should return false for null`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.equals(null)).isFalse() + } + + @Test + fun `equals() should return false for a different type`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult.equals(listOf("foo"))).isFalse() + } + + @Test + fun `equals() should return false when only 'data' differs`() { + val queryResult1 = sampleQuery.QueryResultImpl(TestData("foo")) + val queryResult2 = sampleQuery.QueryResultImpl(TestData("bar")) + + assertThat(queryResult1.equals(queryResult2)).isFalse() + } + + @Test + fun `equals() should return false when only 'ref' differs`() { + val queryResult1 = sampleQuery1.QueryResultImpl(TestData()) + val queryResult2 = sampleQuery2.QueryResultImpl(TestData()) + + assertThat(queryResult1.equals(queryResult2)).isFalse() + } + + @Test + fun `equals() should return false when data of first object is null and second is non-null`() { + val queryResult1 = sampleQuery.QueryResultImpl(null) + val queryResult2 = sampleQuery.QueryResultImpl(TestData("bar")) + + assertThat(queryResult1.equals(queryResult2)).isFalse() + } + + @Test + fun `equals() should return false when data of second object is null and first is non-null`() { + val queryResult1 = sampleQuery.QueryResultImpl(TestData("bar")) + val queryResult2 = sampleQuery.QueryResultImpl(null) + + assertThat(queryResult1.equals(queryResult2)).isFalse() + } + + @Test + fun `hashCode() should return the same value each time it is invoked on a given object`() { + val queryResult = sampleQuery.QueryResultImpl(TestData()) + + val hashCode = queryResult.hashCode() + + assertThat(queryResult.hashCode()).isEqualTo(hashCode) + assertThat(queryResult.hashCode()).isEqualTo(hashCode) + assertThat(queryResult.hashCode()).isEqualTo(hashCode) + } + + @Test + fun `hashCode() should return the same value on equal objects`() { + val queryResult1 = sampleQuery.QueryResultImpl(TestData()) + val queryResult2 = sampleQuery.QueryResultImpl(TestData()) + + assertThat(queryResult1.hashCode()).isEqualTo(queryResult2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if 'data' is different`() { + val queryResult1 = sampleQuery.QueryResultImpl(TestData("foo")) + val queryResult2 = sampleQuery.QueryResultImpl(TestData("bar")) + + assertThat(queryResult1.hashCode()).isNotEqualTo(queryResult2.hashCode()) + } + + @Test + fun `hashCode() should return a different value if 'ref' is different`() { + val queryResult1 = sampleQuery1.QueryResultImpl(TestData()) + val queryResult2 = sampleQuery2.QueryResultImpl(TestData()) + + assertThat(queryResult1.hashCode()).isNotEqualTo(queryResult2.hashCode()) + } + + data class TestVariables(val value: String = "TestVariablesDefaultValue") + + data class TestData(val value: String = "TestDataDefaultValue") +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializerUnitTest.kt new file mode 100644 index 00000000000..8939279bbb0 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializerUnitTest.kt @@ -0,0 +1,292 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.serializers + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Timestamp +import com.google.firebase.dataconnect.testutil.assertThrows +import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToStruct +import kotlinx.serialization.Serializable +import org.junit.Test + +class TimestampSerializerUnitTest { + + @Test + fun `seconds=0 nanoseconds=0 can be encoded and decoded`() { + verifyEncodeDecodeRoundTrip(Timestamp(0, 0)) + } + + @Test + fun `smallest value can be encoded and decoded`() { + verifyEncodeDecodeRoundTrip(Timestamp(-62_135_596_800, 0)) + } + + @Test + fun `largest value can be encoded and decoded`() { + verifyEncodeDecodeRoundTrip(Timestamp(253_402_300_799, 999_999_999)) + } + + @Test + fun `nanoseconds with millisecond precision can be encoded and decoded`() { + verifyEncodeDecodeRoundTrip(Timestamp(130804, 642)) + } + + @Test + fun `nanoseconds with microsecond precision can be encoded and decoded`() { + verifyEncodeDecodeRoundTrip(Timestamp(-46239, 472302)) + } + + @Test + fun `decoding should succeed when 'time-secfrac' is omitted`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05Z")).isEqualTo(Timestamp(1136214245, 0)) + } + + @Test + fun `decoding should succeed when 'time-secfrac' has millisecond precision`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05.123Z")) + .isEqualTo(Timestamp(1136214245, 123_000_000)) + } + + @Test + fun `decoding should succeed when 'time-secfrac' has microsecond precision`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05.123456Z")) + .isEqualTo(Timestamp(1136214245, 123_456_000)) + } + + @Test + fun `decoding should succeed when 'time-secfrac' has nanosecond precision`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05.123456789Z")) + .isEqualTo(Timestamp(1136214245, 123_456_789)) + } + + @Test + fun `decoding should succeed when time offset is 0`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05-00:00")) + .isEqualTo(decodeTimestamp("2006-01-02T15:04:05Z")) + + assertThat(decodeTimestamp("2006-01-02T15:04:05+00:00")) + .isEqualTo(decodeTimestamp("2006-01-02T15:04:05Z")) + } + + @Test + fun `decoding should succeed when time offset is positive`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05+11:01")) + .isEqualTo(decodeTimestamp("2006-01-02T04:03:05Z")) + } + + @Test + fun `decoding should succeed when time offset is negative`() { + assertThat(decodeTimestamp("2006-01-02T15:04:05-05:10")) + .isEqualTo(decodeTimestamp("2006-01-02T20:14:05Z")) + } + + @Test + fun `decoding should succeed when there are both time-secfrac and - time offset`() { + assertThat(decodeTimestamp("2023-05-21T11:04:05.462-11:07")) + .isEqualTo(decodeTimestamp("2023-05-21T22:11:05.462Z")) + + assertThat(decodeTimestamp("2053-11-02T15:04:05.743393-05:10")) + .isEqualTo(decodeTimestamp("2053-11-02T20:14:05.743393Z")) + + assertThat(decodeTimestamp("1538-03-05T15:04:05.653498752-03:01")) + .isEqualTo(decodeTimestamp("1538-03-05T18:05:05.653498752Z")) + } + + @Test + fun `decoding should succeed when there are both time-secfrac and + time offset`() { + assertThat(decodeTimestamp("2023-05-21T11:04:05.662+11:01")) + .isEqualTo(decodeTimestamp("2023-05-21T00:03:05.662Z")) + + assertThat(decodeTimestamp("2144-01-02T15:04:05.753493+01:00")) + .isEqualTo(decodeTimestamp("2144-01-02T14:04:05.753493Z")) + + assertThat(decodeTimestamp("1358-03-05T15:04:05.527094582+11:03")) + .isEqualTo(decodeTimestamp("1358-03-05T04:01:05.527094582Z")) + } + + @Test + fun `decoding should be case-insensitive`() { + // According to https://www.rfc-editor.org/rfc/rfc3339#section-5.6 the "t" and "z" are + // case-insensitive. + assertThat(decodeTimestamp("2006-01-02t15:04:05.123456789z")) + .isEqualTo(decodeTimestamp("2006-01-02T15:04:05.123456789Z")) + } + + @Test + fun `decoding should parse the minimum value officially supported by Data Connect`() { + assertThat(decodeTimestamp("1583-01-01T00:00:00.000000Z")).isEqualTo(Timestamp(-12212553600, 0)) + } + + @Test + fun `decoding should parse the maximum value officially supported by Data Connect`() { + assertThat(decodeTimestamp("9999-12-31T23:59:59.999999999Z")) + .isEqualTo(Timestamp(253402300799, 999999999)) + } + + @Test + fun `decoding should fail for an empty string`() { + assertThrows(IllegalArgumentException::class) { decodeTimestamp("") } + } + + @Test + fun `decoding should fail if 'time-offset' is omitted`() { + assertThrows(IllegalArgumentException::class) { + decodeTimestamp("2006-01-02T15:04:05.123456789") + } + } + + @Test + fun `decoding should fail if 'time-offset' when 'time-secfrac' and time offset are both omitted`() { + assertThrows(IllegalArgumentException::class) { decodeTimestamp("2006-01-02T15:04:05") } + } + + @Test + fun `decoding should fail if the date portion cannot be parsed`() { + assertThrows(IllegalArgumentException::class) { + decodeTimestamp("200X-01-02T15:04:05.123456789Z") + } + } + + @Test + fun `decoding should fail if some character other than period delimits the 'time-secfrac'`() { + assertThrows(IllegalArgumentException::class) { + decodeTimestamp("2006-01-02T15:04:05 123456789Z") + } + } + + @Test + fun `decoding should fail if 'time-secfrac' contains an invalid character`() { + assertThrows(IllegalArgumentException::class) { + decodeTimestamp("2006-01-02T15:04:05.123456X89Z") + } + } + + @Test + fun `decoding should fail if time offset has no + or - sign`() { + assertThrows(IllegalArgumentException::class) { decodeTimestamp("1985-04-12T23:20:5007:00") } + } + + @Test + fun `decoding should fail if time string has mix format`() { + assertThrows(IllegalArgumentException::class) { + decodeTimestamp("2006-01-02T15:04:05-07:00.123456X89Z") + } + } + + @Test + fun `decoding should fail if time offset is not in the correct format`() { + assertThrows(IllegalArgumentException::class) { decodeTimestamp("1985-04-12T23:20:50+7:00") } + } + + @Test + fun `decoding should throw an exception if the timestamp is invalid`() { + invalidTimestampStrs.forEach { + assertThrows(IllegalArgumentException::class) { decodeTimestamp(it) } + } + } + + @Serializable + private data class TimestampWrapper( + @Serializable(with = TimestampSerializer::class) val timestamp: Timestamp + ) + + private companion object { + + fun verifyEncodeDecodeRoundTrip(timestamp: Timestamp) { + val encoded = encodeToStruct(TimestampWrapper(timestamp)) + val decoded = decodeFromStruct(encoded) + assertThat(decoded.timestamp).isEqualTo(timestamp) + } + + fun decodeTimestamp(text: String): Timestamp { + val encodedAsStruct = buildStructProto { put("timestamp", text) } + val decodedStruct = decodeFromStruct(encodedAsStruct) + return decodedStruct.timestamp + } + + // These strings were generated by Gemini + val invalidTimestampStrs = + listOf( + "1985-04-12T23:20:50.123456789", + "1985-04-12T23:20:50.123456789X", + "1985-04-12T23:20:50.123456789+", + "1985-04-12T23:20:50.123456789+07", + "1985-04-12T23:20:50.123456789+07:", + "1985-04-12T23:20:50.123456789+07:0", + "1985-04-12T23:20:50.123456789+07:000", + "1985-04-12T23:20:50.123456789+07:00a", + "1985-04-12T23:20:50.123456789+07:a0", + "1985-04-12T23:20:50.123456789+07::00", + "1985-04-12T23:20:50.123456789+0:00", + "1985-04-12T23:20:50.123456789+00:", + "1985-04-12T23:20:50.123456789+00:0", + "1985-04-12T23:20:50.123456789+00:a", + "1985-04-12T23:20:50.123456789+00:0a", + "1985-04-12T23:20:50.123456789+0:0a", + "1985-04-12T23:20:50.123456789+0:a0", + "1985-04-12T23:20:50.123456789+0::00", + "1985-04-12T23:20:50.123456789-07:0a", + "1985-04-12T23:20:50.123456789-07:a0", + "1985-04-12T23:20:50.123456789-07::00", + "1985-04-12T23:20:50.123456789-0:0a", + "1985-04-12T23:20:50.123456789-0:a0", + "1985-04-12T23:20:50.123456789-0::00", + "1985-04-12T23:20:50.123456789-00:0a", + "1985-04-12T23:20:50.123456789-00:a0", + "1985-04-12T23:20:50.123456789-00::00", + "1985-04-12T23:20:50.123456789-0:00", + "1985-04-12T23:20:50.123456789-00:", + "1985-04-12T23:20:50.123456789-00:0", + "1985-04-12T23:20:50.123456789-00:a", + "1985-04-12T23:20:50.123456789-00:0a", + "1985-04-12T23:20:50.123456789-0:0a", + "1985-04-12T23:20:50.123456789-0:a0", + "1985-04-12T23:20:50.123456789-0::00", + "1985/04/12T23:20:50.123456789Z", + "1985-04-12T23:20:50.123456789Z.", + "1985-04-12T23:20:50.123456789Z..", + "1985-04-12T23:20:50.123456789Z...", + "1985-04-12T23:20:50.123456789+07:00.", + "1985-04-12T23:20:50.123456789+07:00..", + "1985-04-12T23:20:50.123456789+07:00...", + "1985-04-12T23:20:50.123456789-07:00.", + "1985-04-12T23:20:50.123456789-07:00..", + "1985-04-12T23:20:50.123456789-07:00...", + "1985-04-12T23:20:50.1234567890Z", + "1985-04-12T23:20:50.12345678900Z", + "1985-04-12T23:20:50.123456789000Z", + "1985-04-12T23:20:50.1234567890000Z", + "1985-04-12T23:20:50.12345678900000Z", + "1985-04-12T23:20:50.123456789000000Z", + "1985-04-12T23:20:50.1234567890000000Z", + "1985-04-12T23:20:50.12345678900000000Z", + "1985-04-12T23:20:50.1234567891Z", + "1985-04-12T23:20:50.12345678911Z", + "1985-04-12T23:20:50.123456789111Z", + "1985-04-12T23:20:50.1234567891111Z", + "1985-04-12T23:20:50.12345678911111Z", + "1985-04-12T23:20:50.123456789111111Z", + "1985-04-12T23:20:50.1234567891111111Z", + "1985-04-12T23:20:50.12345678911111111Z", + "1985-04-12T23:20:50.123456789000000000Z", + "1985-04-12T23:20:50.1234567890000000000Z", + "1985-04-12T23:20:50.12345678900000000000Z", + ) + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt new file mode 100644 index 00000000000..5ea02ad56c0 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("InternalArbs") + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.DataConnectError +import com.google.firebase.dataconnect.core.DataConnectGrpcClient +import com.google.firebase.dataconnect.core.MutationRefImpl +import com.google.firebase.dataconnect.core.QueryRefImpl +import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.alphanumeric +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.orNull +import io.kotest.property.arbitrary.positiveInt +import io.kotest.property.arbitrary.string +import io.kotest.property.arbs.firstName +import io.mockk.mockk + +internal fun Arb.Companion.dataConnectErrorSourceLocation(): Arb = + arbitrary { + val line = Arb.int(1..9999).bind() + val column = Arb.int(1..9999).bind() + DataConnectError.SourceLocation(line = line, column = column) + } + +internal fun Arb.Companion.dataConnectError(): Arb = arbitrary { + val message = "sx7s673h4n_" + Arb.string(20, codepoints = Codepoint.alphanumeric()).bind() + val numPathSegments = Arb.int(1..3).bind() + val path = List(numPathSegments) { Arb.dataConnectErrorPathSegment().bind() } + val numLocations = Arb.int(0..3).bind() + val locations = List(numLocations) { Arb.dataConnectErrorSourceLocation().bind() } + DataConnectError( + message = message, + path = path, + locations = locations, + ) +} + +internal fun Arb.Companion.dataConnectErrorPathSegmentField(): + Arb = arbitrary { + DataConnectError.PathSegment.Field(Arb.firstName().bind().name) +} + +internal fun Arb.Companion.dataConnectErrorPathSegmentListIndex(): + Arb = arbitrary { + DataConnectError.PathSegment.ListIndex(Arb.positiveInt().bind()) +} + +internal fun Arb.Companion.dataConnectErrorPathSegment(): Arb = + arbitrary { + if (Arb.boolean().bind()) { + Arb.dataConnectErrorPathSegmentField().bind() + } else { + Arb.dataConnectErrorPathSegmentListIndex().bind() + } + } + +internal fun Arb.Companion.operationResult() = arbitrary { + val data = Arb.anyMapScalar().orNull(nullProbability = 0.1).bind()?.toStructProto() + val numErrors = Arb.int(0..3).bind() + val errors = List(numErrors) { Arb.dataConnectError().bind() } + DataConnectGrpcClient.OperationResult(data, errors) +} + +internal fun Arb.Companion.queryRefImpl( + variablesArb: Arb +): Arb> = arbitrary { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + QueryRefImpl( + dataConnect = mockk(stringArb.bind()), + operationName = stringArb.bind(), + variables = variablesArb.bind(), + dataDeserializer = mockk(stringArb.bind()), + variablesSerializer = mockk(stringArb.bind()), + callerSdkType = callerSdkType().bind(), + variablesSerializersModule = mockk(stringArb.bind()), + dataSerializersModule = mockk(stringArb.bind()), + ) +} + +internal fun Arb.Companion.mutationRefImpl( + variablesArb: Arb +): Arb> = arbitrary { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + MutationRefImpl( + dataConnect = mockk(stringArb.bind()), + operationName = stringArb.bind(), + variables = variablesArb.bind(), + dataDeserializer = mockk(stringArb.bind()), + variablesSerializer = mockk(stringArb.bind()), + callerSdkType = callerSdkType().bind(), + variablesSerializersModule = mockk(stringArb.bind()), + dataSerializersModule = mockk(stringArb.bind()), + ) +} + +internal fun Arb.Companion.operationRefImpl( + variablesArb: Arb +): Arb> = arbitrary { + val stringArb = Arb.string(6, codepoints = Codepoint.alphanumeric()) + StubOperationRefImpl( + dataConnect = mockk(stringArb.bind()), + operationName = stringArb.bind(), + variables = variablesArb.bind(), + dataDeserializer = mockk(stringArb.bind()), + variablesSerializer = mockk(stringArb.bind()), + callerSdkType = callerSdkType().bind(), + variablesSerializersModule = mockk(stringArb.bind()), + dataSerializersModule = mockk(stringArb.bind()), + ) +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectAnySerializer.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectAnySerializer.kt new file mode 100644 index 00000000000..49082914e8f --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectAnySerializer.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.util.ProtoUtil.nullProtoValue +import com.google.firebase.dataconnect.util.ProtoUtil.toListOfAny +import com.google.firebase.dataconnect.util.ProtoUtil.toMap +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import com.google.firebase.dataconnect.util.ProtoValueDecoder +import com.google.firebase.dataconnect.util.ProtoValueEncoder +import com.google.protobuf.Value +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +// Adapted from JsonContentPolymorphicSerializer +// https://github.com/Kotlin/kotlinx.serialization/blob/8c84a5b4dd/formats/json/commonMain/src/kotlinx/serialization/json/JsonContentPolymorphicSerializer.kt#L67 +object DataConnectAnySerializer : KSerializer { + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor = + buildSerialDescriptor("DataConnectAnySerializer", PolymorphicKind.SEALED) + + @Suppress("UNCHECKED_CAST") + override fun serialize(encoder: Encoder, value: Any?) { + require(encoder is ProtoValueEncoder) { + "DataConnectAnySerializer only supports ProtoValueEncoder" + + ", but got ${encoder::class.qualifiedName}" + } + val protoValue = + when (value) { + null -> nullProtoValue + is String -> value.toValueProto() + is Double -> value.toValueProto() + is Boolean -> value.toValueProto() + is List<*> -> value.toValueProto() + is Map<*, *> -> (value as Map).toValueProto() + else -> + throw IllegalArgumentException( + "unsupported type: ${value::class.qualifiedName} (error code: av5kpmwb8h)" + ) + } + encoder.onValue(protoValue) + } + + override fun deserialize(decoder: Decoder): Any? { + require(decoder is ProtoValueDecoder) { + "DataConnectAnySerializer only supports ProtoValueDecoder" + + ", but got ${decoder::class.qualifiedName}" + } + return when (val kindCase = decoder.valueProto.kindCase) { + Value.KindCase.NULL_VALUE -> null + Value.KindCase.STRING_VALUE -> decoder.valueProto.stringValue + Value.KindCase.NUMBER_VALUE -> decoder.valueProto.numberValue + Value.KindCase.BOOL_VALUE -> decoder.valueProto.boolValue + Value.KindCase.LIST_VALUE -> decoder.valueProto.listValue.toListOfAny() + Value.KindCase.STRUCT_VALUE -> decoder.valueProto.structValue.toMap() + else -> + throw IllegalArgumentException("unsupported KindCase: $kindCase (error code: 3bde44vczt)") + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/MockLogger.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/MockLogger.kt new file mode 100644 index 00000000000..a8e0999fc1e --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/MockLogger.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.LogLevel +import com.google.firebase.dataconnect.core.Logger +import com.google.firebase.dataconnect.core.LoggerGlobals.Logger +import io.mockk.Matcher +import io.mockk.MockKMatcherScope +import io.mockk.every +import io.mockk.excludeRecords +import io.mockk.spyk +import io.mockk.verify +import java.util.regex.Pattern + +internal fun newMockLogger(key: String, emit: (String) -> Unit = {}): Logger { + val name = "mockLogger-$key" + return spyk(Logger(name), name = name) { + every { log(any(), any(), any()) } answers + { + val exception: Throwable? = firstArg() + val level: LogLevel = secondArg() + val message: String = thirdArg() + if (exception === null) { + emit("$name [$level] $message") + } else { + emit("$name [$level] $message ($exception)") + } + } + excludeRecords { + this@spyk.name + this@spyk.nameWithId + this@spyk.toString() + } + } +} + +private data class LoggedMessageContainsMatcher(val text: String, val ignoreCase: Boolean) : + Matcher { + private val pattern = "(^|\\W)${Pattern.quote(text)}($|\\W)" + private val expr = Pattern.compile(pattern, if (ignoreCase) Pattern.CASE_INSENSITIVE else 0) + + override fun match(arg: String?) = if (arg === null) false else expr.matcher(arg).find() + + override fun toString(): String = "loggedMessageContains(\"$text\", ignoreCase=$ignoreCase)" +} + +private fun MockKMatcherScope.matchStringWithNonAdjacentText( + text: String, + ignoreCase: Boolean = false +) = match(LoggedMessageContainsMatcher(text, ignoreCase)) + +internal fun Logger.shouldHaveLoggedAtLeastOneMessageContaining( + text: String, + ignoreCase: Boolean = false +) { + verify(atLeast = 1) { log(any(), any(), matchStringWithNonAdjacentText(text, ignoreCase)) } +} + +internal fun Logger.shouldHaveLoggedExactlyOneMessageContaining( + text: String, + ignoreCase: Boolean = false +) { + verify(exactly = 1) { log(any(), any(), matchStringWithNonAdjacentText(text, ignoreCase)) } +} + +internal fun Logger.shouldHaveLoggedExactlyOneMessageContaining( + text: String, + exception: Throwable, + ignoreCase: Boolean = false +) { + verify(exactly = 1) { + log(refEq(exception), any(), matchStringWithNonAdjacentText(text, ignoreCase)) + } +} + +internal fun Logger.shouldNotHaveLoggedAnyMessagesContaining( + text: String, + ignoreCase: Boolean = false +) { + verify(inverse = true) { log(any(), any(), matchStringWithNonAdjacentText(text, ignoreCase)) } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Stubs.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Stubs.kt new file mode 100644 index 00000000000..bdba941d00f --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Stubs.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.core.FirebaseDataConnectInternal +import com.google.firebase.dataconnect.core.OperationRefImpl +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.SerializersModule + +internal class StubOperationRefImpl( + dataConnect: FirebaseDataConnectInternal, + operationName: String, + variables: Variables, + dataDeserializer: DeserializationStrategy, + variablesSerializer: SerializationStrategy, + callerSdkType: FirebaseDataConnect.CallerSdkType, + variablesSerializersModule: SerializersModule?, + dataSerializersModule: SerializersModule?, +) : + OperationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) { + override suspend fun execute(): OperationResultImpl { + throw UnsupportedOperationException("this stub method is not supported") + } +} + +internal fun StubOperationRefImpl.copy( + dataConnect: FirebaseDataConnectInternal = this.dataConnect, + operationName: String = this.operationName, + variables: Variables = this.variables, + dataDeserializer: DeserializationStrategy = this.dataDeserializer, + variablesSerializer: SerializationStrategy = this.variablesSerializer, + callerSdkType: FirebaseDataConnect.CallerSdkType = this.callerSdkType, + variablesSerializersModule: SerializersModule? = this.variablesSerializersModule, + dataSerializersModule: SerializersModule? = this.dataSerializersModule, +): StubOperationRefImpl = + StubOperationRefImpl( + dataConnect = dataConnect, + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = variablesSerializer, + callerSdkType = callerSdkType, + variablesSerializersModule = variablesSerializersModule, + dataSerializersModule = dataSerializersModule, + ) diff --git a/firebase-dataconnect/testutil/README.md b/firebase-dataconnect/testutil/README.md new file mode 100644 index 00000000000..04ac7a031bd --- /dev/null +++ b/firebase-dataconnect/testutil/README.md @@ -0,0 +1,4 @@ +An android library project to share code between the firebase-dataconnect +unit tests _and_ instrumentation tests. + +See https://github.com/android/architecture-samples and https://stackoverflow.com/q/72218645 diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/FirebaseAppTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/FirebaseAppTestUtils.kt new file mode 100644 index 00000000000..fadd15fd62e --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/FirebaseAppTestUtils.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase + +import android.annotation.SuppressLint + +object FirebaseAppTestUtils { + + @SuppressLint("RestrictedApi", "VisibleForTests") + fun initializeAllComponents(app: FirebaseApp) { + app.initializeAllComponents() + } + + @SuppressLint("RestrictedApi", "VisibleForTests") + fun clearInstancesForTest() { + FirebaseApp.clearInstancesForTest() + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AccessTokenTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AccessTokenTestUtils.kt new file mode 100644 index 00000000000..4d03c8e8ff5 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AccessTokenTestUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +// This is a copy of the function of the same name in DataConnectCredentialsTokenManager.kt. +fun String.toScrubbedAccessToken(): String = + if (length < 30) { + "" + } else { + buildString { + append(this@toScrubbedAccessToken, 0, 6) + append("") + append( + this@toScrubbedAccessToken, + this@toScrubbedAccessToken.length - 6, + this@toScrubbedAccessToken.length + ) + } + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AnyScalarTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AnyScalarTestUtils.kt new file mode 100644 index 00000000000..e7837c567ad --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AnyScalarTestUtils.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +@JvmName("expectedAnyScalarRoundTripValueOrNull") +fun expectedAnyScalarRoundTripValue(value: Any?): Any? = + if (value === null) null else expectedAnyScalarRoundTripValue(value) + +fun expectedAnyScalarRoundTripValue(value: Any): Any = + when (value) { + -0.0 -> 0.0 + Double.NaN -> "NaN" + Double.POSITIVE_INFINITY -> "Infinity" + Double.NEGATIVE_INFINITY -> "-Infinity" + is List<*> -> value.map { expectedAnyScalarRoundTripValue(it) } + is Map<*, *> -> value.mapValues { expectedAnyScalarRoundTripValue(it.value) } + else -> value + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt new file mode 100644 index 00000000000..836e3d99b2e --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.ConnectorConfig +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType +import com.google.firebase.util.nextAlphanumericString +import io.kotest.property.Arb +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.arabic +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.ascii +import io.kotest.property.arbitrary.boolean +import io.kotest.property.arbitrary.choice +import io.kotest.property.arbitrary.cyrillic +import io.kotest.property.arbitrary.double +import io.kotest.property.arbitrary.egyptianHieroglyphs +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.filterIsInstance +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.merge +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.of +import io.kotest.property.arbitrary.string + +fun Arb.filterNotNull(): Arb = filter { it !== null }.map { it!! } + +fun Arb.filterNotEqual(other: A) = filter { it != other } + +fun Arb.Companion.keyedString(id: String, key: String, length: Int = 8): Arb = + arbitrary { rs -> + "${id}_${key}_${rs.random.nextAlphanumericString(length = length)}" + } + +fun Arb.Companion.connectorConfig( + key: String, + connector: Arb = connectorName(key), + location: Arb = connectorLocation(key), + serviceId: Arb = connectorServiceId(key) +): Arb = arbitrary { rs -> + ConnectorConfig( + connector = connector.next(rs), + location = location.next(rs), + serviceId = serviceId.next(rs), + ) +} + +fun Arb.Companion.connectorName(key: String): Arb = keyedString("connector", key) + +fun Arb.Companion.connectorLocation(key: String): Arb = keyedString("location", key) + +fun Arb.Companion.connectorServiceId(key: String): Arb = keyedString("serviceId", key) + +fun Arb.Companion.accessToken(key: String): Arb = + keyedString("accessToken", key, length = 20) + +fun Arb.Companion.requestId(key: String): Arb = keyedString("requestId", key) + +fun Arb.Companion.operationName(key: String): Arb = keyedString("operation", key) + +fun Arb.Companion.projectId(key: String): Arb = keyedString("project", key) + +fun Arb.Companion.host(key: String): Arb = keyedString("host", key) + +fun Arb.Companion.dataConnectSettings( + key: String, + host: Arb = host(key), + sslEnabled: Arb = Arb.boolean(), +): Arb = arbitrary { rs -> + DataConnectSettings(host = host.next(rs), sslEnabled = sslEnabled.next(rs)) +} + +fun Arb.Companion.anyNumberScalar(): Arb = anyScalar().filterIsInstance() + +fun Arb.Companion.anyStringScalar(): Arb = anyScalar().filterIsInstance() + +fun Arb.Companion.anyListScalar(): Arb> = anyScalar().filterIsInstance>() + +fun Arb.Companion.anyMapScalar(): Arb> = + anyScalar().filterIsInstance>() + +fun Arb.Companion.anyScalar(): Arb = + arbitrary(edgecases = EdgeCases.anyScalars) { + // Put the arbs into an `object` so that `lists`, `maps`, and `allValues` can contain + // circular references to each other. + val anyScalarArbs = + object { + val booleans = Arb.boolean() + val numbers = Arb.double() + val nulls = Arb.of(null) + + val codepoints = + Codepoint.ascii() + .merge(Codepoint.egyptianHieroglyphs()) + .merge(Codepoint.arabic()) + .merge(Codepoint.cyrillic()) + // Do not produce character code 0 because it's not supported by Postgresql: + // https://www.postgresql.org/docs/current/datatype-character.html + .filterNot { it.value == 0 } + val strings = Arb.string(minSize = 1, maxSize = 40, codepoints = codepoints) + + val lists: Arb> = arbitrary { + val size = Arb.int(1..3).bind() + List(size) { allValues.bind() } + } + + val maps: Arb> = arbitrary { + buildMap { + val size = Arb.int(1..3).bind() + repeat(size) { put(strings.bind(), allValues.bind()) } + } + } + + val allValues: Arb = Arb.choice(booleans, numbers, strings, nulls, lists, maps) + } + + anyScalarArbs.allValues.bind() + } + +fun Arb.filterNotAnyScalarMatching(value: Any?) = filter { + if (it == value) { + false + } else if (it === null || value === null) { + true + } else { + expectedAnyScalarRoundTripValue(it) != expectedAnyScalarRoundTripValue(value) + } +} + +fun Arb>.filterNotIncludesAllMatchingAnyScalars(values: List) = filter { + require(values.isNotEmpty()) { "values must not be empty" } + + val allValues = buildList { + for (value in it) { + add(value) + add(expectedAnyScalarRoundTripValue(value)) + } + } + + !values + .map { Pair(it, expectedAnyScalarRoundTripValue(it)) } + .map { allValues.contains(it.first) || allValues.contains(it.second) } + .reduce { acc, contained -> acc && contained } +} + +fun Arb.Companion.callerSdkType(): Arb = arbitrary { + if (Arb.boolean().bind()) CallerSdkType.Base else CallerSdkType.Generated +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectLogLevelRule.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectLogLevelRule.kt new file mode 100644 index 00000000000..67bbc00b4d2 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectLogLevelRule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.LogLevel +import com.google.firebase.dataconnect.logLevel +import org.junit.rules.ExternalResource + +/** + * A JUnit test rule that sets the Firebase Data Connect log level to the desired level, then + * restores it upon completion of the test. + */ +class DataConnectLogLevelRule(val logLevelDuringTest: LogLevel? = LogLevel.DEBUG) : + ExternalResource() { + + private lateinit var logLevelBefore: LogLevel + + override fun before() { + logLevelBefore = FirebaseDataConnect.logLevel + logLevelDuringTest?.also { FirebaseDataConnect.logLevel = it } + } + + override fun after() { + FirebaseDataConnect.logLevel = logLevelBefore + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DateTimeTestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DateTimeTestUtils.kt new file mode 100644 index 00000000000..0179de17bc5 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DateTimeTestUtils.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.Timestamp +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.TimeZone +import kotlin.random.Random +import kotlin.random.nextInt + +/** + * Creates and returns a new [Date] object that represents the given year, month, and day in UTC. + * + * @param year The year; must be between 0 and 9999, inclusive. + * @param month The month; must be between 1 and 12, inclusive. + * @param day The day of the month; must be between 1 and 31, inclusive. + */ +fun dateFromYearMonthDayUTC(year: Int, month: Int, day: Int): Date { + require(year in 0..9999) { "year must be between 0 and 9999, inclusive" } + require(month in 1..12) { "month must be between 1 and 12, inclusive" } + require(day in 1..31) { "day must be between 1 and 31, inclusive" } + + return GregorianCalendar(TimeZone.getTimeZone("UTC")) + .apply { + set(year, month - 1, day, 0, 0, 0) + set(Calendar.MILLISECOND, 0) + } + .time +} + +val MIN_DATE: Date + get() = dateFromYearMonthDayUTC(1583, 1, 1) + +val MAX_DATE: Date + get() = dateFromYearMonthDayUTC(9999, 12, 31) + +val ZERO_DATE: Date + get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).apply { timeInMillis = 0 }.time + +/** + * Generates and returns a random [Date] object with hour, minute, and second set to zero. + * + * @see https://en.wikipedia.org/wiki/ISO_8601#Years for rationale of lower bound of 1583. + */ +fun randomDate(): Date = + dateFromYearMonthDayUTC( + year = Random.nextInt(1583..9999), + month = Random.nextInt(1..12), + day = Random.nextInt(1..28) + ) + +/** Generates and returns a random [Timestamp] object. */ +fun randomTimestamp(): Timestamp { + val nanoseconds = Random.nextInt(1_000_000_000) + val seconds = Random.nextLong(MIN_TIMESTAMP.seconds, MAX_TIMESTAMP.seconds) + return Timestamp(seconds, nanoseconds) +} + +fun Timestamp.withMicrosecondPrecision(): Timestamp { + val result = Timestamp(seconds, ((nanoseconds.toLong() / 1_000) * 1_000).toInt()) + return result +} + +// "1583-01-01T00:00:00.000000Z" +val MIN_TIMESTAMP + get() = Timestamp(-12_212_553_600, 0) + +// "9999-12-31T23:59:59.999999999Z" +val MAX_TIMESTAMP + get() = Timestamp(253_402_300_799, 999_999_999) + +val ZERO_TIMESTAMP: Timestamp + get() = Timestamp(0, 0) + +/** + * Creates and returns a new [Timestamp] object that represents the given date and time. + * + * @param year The year; must be between 0 and 9999, inclusive. + * @param month The month; must be between 1 and 12, inclusive. + * @param day The day of the month; must be between 1 and 31, inclusive. + */ +fun timestampFromUTCDateAndTime( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, + nanoseconds: Int +): Timestamp { + require(year in 0..9999) { "year must be between 0 and 9999, inclusive" } + require(month in 1..12) { "month must be between 1 and 12, inclusive" } + require(day in 1..31) { "day must be between 1 and 31, inclusive" } + require(hour in 0..24) { "hour must be between 0 and 23, inclusive" } + require(minute in 0..59) { "minute must be between 0 and 59, inclusive" } + require(second in 0..60) { "second must be between 0 and 60, inclusive" } + require(nanoseconds in 0..999_999_999) { + "nanoseconds must be between 0 and 999,999,999, inclusive" + } + + val seconds = + GregorianCalendar(TimeZone.getTimeZone("UTC")) + .apply { + set(year, month - 1, day, hour, minute, second) + set(Calendar.MILLISECOND, 0) + } + .timeInMillis / 1000 + + return Timestamp(seconds, nanoseconds) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DelayedDeferred.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DelayedDeferred.kt new file mode 100644 index 00000000000..b44b4ac3341 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DelayedDeferred.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.inject.Deferred +import com.google.firebase.inject.Provider +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * An implementation of [Deferred] whose provider is initially unavailable, then becomes available + * when [makeAvailable] is invoked. + * + * The callback registered with [whenAvailable] is _always_ called back asynchronously, even if the + * instance has already been registered. + */ +@OptIn(DelicateCoroutinesApi::class) +class DelayedDeferred(instance: T) : Deferred { + private val provider = Provider { instance } + private val mutex = Mutex() + private var provided = false + private val handlers = mutableListOf>() + + override fun whenAvailable(handler: Deferred.DeferredHandler) { + GlobalScope.launch(Dispatchers.Default) { + val notifyHandler = + mutex.withLock { + if (provided) { + true + } else { + handlers.add(handler) + false + } + } + if (notifyHandler) { + handler.handle(provider) + } + } + } + + suspend fun makeAvailable() { + val capturedHandlers = + mutex.withLock { + provided = true + val capturedHandlers = handlers.toList() + handlers.clear() + capturedHandlers + } + GlobalScope.launch(Dispatchers.Default) { capturedHandlers.forEach { it.handle(provider) } } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EdgeCases.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EdgeCases.kt new file mode 100644 index 00000000000..a0d8d4088c6 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EdgeCases.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +object EdgeCases { + + val numbers: List = + listOf( + -1.0, + -Double.MIN_VALUE, + -0.0, + 0.0, + Double.MIN_VALUE, + 1.0, + Double.NEGATIVE_INFINITY, + Double.NaN, + Double.POSITIVE_INFINITY + ) + + val strings: List = listOf("") + + val booleans: List = listOf(true, false) + + val primitives: List = numbers + strings + booleans + + val lists: List> = buildList { + add(emptyList()) + add(listOf(null)) + add(listOf(emptyList())) + add(listOf(emptyMap())) + add(listOf(listOf(null))) + add(listOf(mapOf("bansj8ayck" to emptyList()))) + add(listOf(mapOf("mjstqe4bt4" to listOf(null)))) + add(primitives) + add(listOf(primitives)) + add(listOf(mapOf("hw888awmnr" to primitives))) + add(listOf(mapOf("29vphvjzpr" to listOf(primitives)))) + for (primitiveEdgeCase in primitives) { + add(listOf(primitiveEdgeCase)) + add(listOf(listOf(primitiveEdgeCase))) + add(listOf(mapOf("me74x5fqgy" to listOf(primitiveEdgeCase)))) + add(listOf(mapOf("v2rj5cmhsm" to listOf(listOf(primitiveEdgeCase))))) + } + } + + val maps: List> = buildList { + add(emptyMap()) + add(mapOf("" to null)) + add(mapOf("fzjfmcrqwe" to emptyMap())) + add(mapOf("g3a2sgytnd" to emptyList())) + add(mapOf("qywfwqnb6p" to mapOf("84gszc54nh" to null))) + add(mapOf("zeb85c3xbr" to mapOf("t6mzt385km" to emptyMap()))) + add(mapOf("ew85krxvmv" to mapOf("w8a2myv5yj" to emptyList()))) + add(mapOf("k3ytrrk2n6" to mapOf("hncgdwa2wt" to primitives))) + add(mapOf("yr2xpxczd8" to mapOf("s76y7jh9wa" to mapOf("g28wzy56k4" to primitives)))) + add( + buildMap { + for (primitiveEdgeCase in primitives) { + put("pn9a9nz8b3_$primitiveEdgeCase", primitiveEdgeCase) + } + } + ) + for (primitiveEdgeCase in primitives) { + add(mapOf("yq7j7n72tc" to primitiveEdgeCase)) + add(mapOf("qsdbfeygnf" to mapOf("33rsz2mjpr" to primitiveEdgeCase))) + add(mapOf("kyjkx5epga" to listOf(primitiveEdgeCase))) + } + } + + val anyScalars: List = primitives + lists + maps + listOf(null) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EmptyVariables.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EmptyVariables.kt new file mode 100644 index 00000000000..a2502ff66b5 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EmptyVariables.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.generated.GeneratedOperation +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +/** + * A class whose serializer writes nothing when serialized. + * + * This can be used to test the server behavior when required variables are absent. + */ +@Serializable(with = EmptyVariablesSerializer::class) object EmptyVariables + +private class EmptyVariablesSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("EmptyVariables", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): EmptyVariables = throw UnsupportedOperationException() + + override fun serialize(encoder: Encoder, value: EmptyVariables) { + // do nothing + } +} + +suspend fun GeneratedOperation<*, Data, *>.executeWithEmptyVariables() = + withVariablesSerializer(serializer()).ref(EmptyVariables).execute() diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FactoryTestRule.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FactoryTestRule.kt new file mode 100644 index 00000000000..c6345a391f8 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FactoryTestRule.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import org.junit.rules.ExternalResource + +/** + * A JUnit test rule that creates instances of an object for use during testing, and cleans them + * upon test completion. + */ +abstract class FactoryTestRule : ExternalResource() { + + private val active = AtomicBoolean(false) + private val instances = CopyOnWriteArrayList() + + fun newInstance(params: P? = null): T { + check(active.get()) { "newInstance() may only be called during the test's execution" } + val instance = createInstance(params) + instances.add(instance) + return instance + } + + fun adoptInstance(instance: T) { + check(active.get()) { "adoptInstance() may only be called during the test's execution" } + instances.add(instance) + } + + override fun before() { + active.set(true) + } + + override fun after() { + active.set(false) + while (instances.isNotEmpty()) { + destroyInstance(instances.removeLast()) + } + } + + protected abstract fun createInstance(params: P?): T + protected abstract fun destroyInstance(instance: T) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppUnitTestingRule.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppUnitTestingRule.kt new file mode 100644 index 00000000000..63208f8121f --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppUnitTestingRule.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseAppTestUtils +import com.google.firebase.FirebaseOptions +import com.google.firebase.initialize + +/** + * A JUnit rule for use in _unit_ tests (not _integration_ tests) that sets up the default + * [FirebaseApp] instance before the test, and deletes is _after_ the test. It can also be used to + * create non-default instances of [FirebaseApp] by calling [newInstance]. + * + * Unit tests using this rule must be in classes annotated with `@RunWith(AndroidJUnit4::class)`. + * + * The [appNameKey], [applicationIdKey], and [projectIdKey] should all be globally unique strings + * and will be incorporated into [FirebaseApp] instances that this object creates. Using such values + * enables easily correlating instances back to the place in the source code where they are created. + * + * If an error occurs at runtime like this: + * ``` + * No instrumentation registered! Must run under a registering instrumentation. + * ``` + * then make sure that the class is annotated with `@RunWith(AndroidJUnit4::class)`. + * + * Example: + * ``` + * import androidx.test.ext.junit.runners.AndroidJUnit4 + * + * @RunWith(AndroidJUnit4::class) + * class MyTest { + * @get:Rule + * val firebaseAppFactory = FirebaseAppUnitTestingRule( + * appNameKey = "bsv6ag4m76", + * applicationIdKey = "52jdwgz9s9", + * projectIdKey = "pf9yk3m5jw" + * ) + * } + * ``` + */ +class FirebaseAppUnitTestingRule( + private val appNameKey: String, + private val applicationIdKey: String, + private val projectIdKey: String, +) : FactoryTestRule() { + + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + override fun createInstance(params: Nothing?) = createInstance(randomAppName(appNameKey)) + + private fun createInstance(appName: String): FirebaseApp { + val firebaseOptions = newFirebaseOptions() + val app = Firebase.initialize(context, firebaseOptions, appName) + FirebaseAppTestUtils.initializeAllComponents(app) + return app + } + + private fun initializeDefaultApp(): FirebaseApp = createInstance(FirebaseApp.DEFAULT_APP_NAME) + + override fun destroyInstance(instance: FirebaseApp) { + instance.delete() + } + + override fun before() { + super.before() + FirebaseAppTestUtils.clearInstancesForTest() + initializeDefaultApp() + } + + override fun after() { + FirebaseAppTestUtils.clearInstancesForTest() + super.after() + } + + private fun newFirebaseOptions() = + FirebaseOptions.Builder() + .setApplicationId(randomApplicationId(applicationIdKey)) + .setProjectId(randomProjectId(projectIdKey)) + .build() +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ImmediateDeferred.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ImmediateDeferred.kt new file mode 100644 index 00000000000..36e7d752717 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ImmediateDeferred.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.inject.Deferred +import com.google.firebase.inject.Provider + +/** An implementation of {@link Deferred} whose provider is always available. */ +class ImmediateDeferred(instance: T) : Deferred { + + private val provider = Provider { instance } + + override fun whenAvailable(handler: Deferred.DeferredHandler) { + handler.handle(provider) + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/KotestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/KotestUtils.kt new file mode 100644 index 00000000000..c2fd48a6155 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/KotestUtils.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.arbitrary.next + +interface ArbIterator { + fun next(rs: RandomSource): T +} + +fun Arb.iterator(edgeCaseProbability: Float): ArbIterator { + require(edgeCaseProbability in 0.0..1.0) { "invalid edgeCaseProbability: $edgeCaseProbability" } + return object : ArbIterator { + override fun next(rs: RandomSource) = + if (edgeCaseProbability == 1.0f || edgeCaseProbability < rs.random.nextFloat()) + this@iterator.edgecase(rs)!! + else this@iterator.next(rs) + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/MutationRefTestExtensions.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/MutationRefTestExtensions.kt new file mode 100644 index 00000000000..70504e32078 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/MutationRefTestExtensions.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.* +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer + +fun MutationRef<*, Variables>.withDataDeserializer( + deserializer: DeserializationStrategy +): MutationRef = + dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = deserializer, + variablesSerializer = variablesSerializer + ) + +fun MutationRef.withVariables( + variables: NewVariables, + serializer: SerializationStrategy +): MutationRef = + dataConnect.mutation( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = serializer + ) + +inline fun MutationRef.withVariables( + variables: NewVariables +): MutationRef = withVariables(variables, serializer()) diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/QueryRefTestExtensions.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/QueryRefTestExtensions.kt new file mode 100644 index 00000000000..0025b1e9e4d --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/QueryRefTestExtensions.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.dataconnect.QueryRef +import com.google.firebase.dataconnect.generated.GeneratedConnector +import com.google.firebase.dataconnect.generated.GeneratedMutation +import com.google.firebase.dataconnect.generated.GeneratedOperation +import com.google.firebase.dataconnect.generated.GeneratedQuery +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.serializer + +fun QueryRef.withDataDeserializer( + newDataDeserializer: DeserializationStrategy +): QueryRef = + dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = newDataDeserializer, + variablesSerializer = variablesSerializer + ) + +fun QueryRef.withVariables( + variables: NewVariables, + serializer: SerializationStrategy +): QueryRef = + dataConnect.query( + operationName = operationName, + variables = variables, + dataDeserializer = dataDeserializer, + variablesSerializer = serializer + ) + +inline fun QueryRef.withVariables( + variables: NewVariables +): QueryRef = withVariables(variables, serializer()) + +fun GeneratedOperation + .withVariablesSerializer(variablesSerializer: SerializationStrategy) = + object : GeneratedOperation { + override val connector by this@withVariablesSerializer::connector + override val operationName by this@withVariablesSerializer::operationName + override val dataDeserializer by this@withVariablesSerializer::dataDeserializer + override val variablesSerializer + get() = variablesSerializer + + override fun toString(): String = + this@withVariablesSerializer.toString() + " with variables serializer $variablesSerializer" + } + +fun GeneratedQuery.withVariablesSerializer( + variablesSerializer: SerializationStrategy +) = + object : GeneratedQuery { + override val connector by this@withVariablesSerializer::connector + override val operationName by this@withVariablesSerializer::operationName + override val dataDeserializer by this@withVariablesSerializer::dataDeserializer + override val variablesSerializer + get() = variablesSerializer + + override fun toString(): String = + this@withVariablesSerializer.toString() + " with variables serializer $variablesSerializer" + } + +fun GeneratedMutation + .withVariablesSerializer(variablesSerializer: SerializationStrategy) = + object : GeneratedMutation { + override val connector by this@withVariablesSerializer::connector + override val operationName by this@withVariablesSerializer::operationName + override val dataDeserializer by this@withVariablesSerializer::dataDeserializer + override val variablesSerializer + get() = variablesSerializer + + override fun toString(): String = + this@withVariablesSerializer.toString() + " with variables serializer $variablesSerializer" + } + +fun GeneratedOperation + .withDataDeserializer(dataDeserializer: DeserializationStrategy) = + object : GeneratedOperation { + override val connector by this@withDataDeserializer::connector + override val operationName by this@withDataDeserializer::operationName + override val dataDeserializer + get() = dataDeserializer + override val variablesSerializer by this@withDataDeserializer::variablesSerializer + + override fun toString(): String = + this@withDataDeserializer.toString() + " with data deserializer $dataDeserializer" + } + +fun GeneratedQuery + .withDataDeserializer(dataDeserializer: DeserializationStrategy) = + object : GeneratedQuery { + override val connector by this@withDataDeserializer::connector + override val operationName by this@withDataDeserializer::operationName + override val dataDeserializer + get() = dataDeserializer + override val variablesSerializer by this@withDataDeserializer::variablesSerializer + + override fun toString(): String = + this@withDataDeserializer.toString() + " with data deserializer $dataDeserializer" + } + +fun GeneratedMutation + .withDataDeserializer(dataDeserializer: DeserializationStrategy) = + object : GeneratedMutation { + override val connector by this@withDataDeserializer::connector + override val operationName by this@withDataDeserializer::operationName + override val dataDeserializer + get() = dataDeserializer + override val variablesSerializer by this@withDataDeserializer::variablesSerializer + + override fun toString(): String = + this@withDataDeserializer.toString() + " with data deserializer $dataDeserializer" + } diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/RandomSeedTestRule.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/RandomSeedTestRule.kt new file mode 100644 index 00000000000..e58a3cfdf41 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/RandomSeedTestRule.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.firebase.dataconnect.testutil + +import io.kotest.property.RandomSource +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * A JUnit test rule that prints the seed of a [RandomSource] if the test fails, enabling replaying + * the test with the same seed to investigate failures. If the [Lazy] is never initialized, then the + * random seed is _not_ printed. + */ +class RandomSeedTestRule(val rs: Lazy) : TestRule { + + constructor() : this(lazy(LazyThreadSafetyMode.PUBLICATION) { RandomSource.default() }) + + override fun apply(base: Statement, description: Description) = + object : Statement() { + override fun evaluate() { + val result = base.runCatching { evaluate() } + result.onFailure { + if (rs.isInitialized()) { + println( + "55negqf33k Test ${description.displayName} failed using " + + "RandomSource with seed=${rs.value.seed}" + ) + } + } + result.getOrThrow() + } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt new file mode 100644 index 00000000000..7098a390886 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +/** + * An implementation of [java.util.concurrent.CountDownLatch] that suspends instead of blocking. + * @param count the number of times [countDown] must be invoked before threads can pass through + * [await]. + * @throws IllegalArgumentException if `count` is negative + */ +class SuspendingCountDownLatch(count: Int) { + init { + require(count > 0) { "invalid count: $count" } + } + + private val _count = MutableStateFlow(count) + val count: Int by _count::value + + /** + * Causes the current coroutine to suspend until the latch has counted down to zero, unless the + * coroutine is cancelled. + * + * If the current count is zero then this method returns immediately. + * + * If the current count is greater than zero then the current coroutine suspends until one of two + * things happen: + * 1. The count reaches zero due to invocations of the [countDown] method; or + * 2. The calling coroutine is cancelled. + */ + suspend fun await() { + _count.filter { it == 0 }.first() + } + + /** + * Decrements the count of the latch, un-suspending all waiting coroutines if the count reaches + * zero. + * + * If the current count is greater than zero then it is decremented. If the new count is zero then + * all waiting coroutines are re-enabled for scheduling on their respective dispatchers. + * + * @return returns this object, to make it easy to chain it with [await]. + * @throws IllegalStateException if called when the count has already reached zero. + */ + fun countDown(): SuspendingCountDownLatch { + while (true) { + val oldValue = _count.value + check(oldValue > 0) { "countDown() called too many times (oldValue=$oldValue)" } + + val newValue = oldValue - 1 + if (_count.compareAndSet(oldValue, newValue)) { + return this + } + } + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestUtils.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestUtils.kt new file mode 100644 index 00000000000..59825863067 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestUtils.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.common.truth.StringSubject +import com.google.firebase.FirebaseApp +import com.google.firebase.dataconnect.DataConnectSettings +import com.google.firebase.dataconnect.OperationRef +import com.google.firebase.util.nextAlphanumericString +import java.util.UUID +import java.util.regex.Pattern +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.random.Random +import kotlin.reflect.KClass +import kotlin.reflect.safeCast +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.withContext +import org.junit.Assert + +/** + * Asserts that a string contains another string, verifying that the character immediately preceding + * the text, if any, is a non-word character, and that the character immediately following the text, + * if any, is also a non-word character. This effectively verifies that the given string is included + * in the string being checked without being "mashed" into adjacent text. + */ +fun StringSubject.containsWithNonAdjacentText(text: String, ignoreCase: Boolean = false) = + containsMatchWithNonAdjacentText(Pattern.quote(text), ignoreCase = ignoreCase) + +/** + * Asserts that a string contains a pattern, verifying that the character immediately preceding the + * text, if any, is a non-word character, and that the character immediately following the text, if + * any, is also a non-word character. This effectively verifies that the given pattern is included + * in the string being checked without being "mashed" into adjacent text. + */ +fun StringSubject.containsMatchWithNonAdjacentText(pattern: String, ignoreCase: Boolean = false) { + val fullPattern = "(^|\\W)${pattern}($|\\W)" + val expr = Pattern.compile(fullPattern, if (ignoreCase) Pattern.CASE_INSENSITIVE else 0) + containsMatch(expr) +} + +/** + * Calls [kotlinx.coroutines.delay] in such a way that it _really_ will delay, even when called from + * [kotlinx.coroutines.test.runTest], which _skips_ delays. This is achieved by switching contexts + * to a dispatcher that does _not_ use the [kotlinx.coroutines.test.TestCoroutineScheduler] + * scheduler and, therefore, will actually delay, as measured by a wall clock. + */ +suspend fun delayIgnoringTestScheduler(duration: Duration) { + withContext(Dispatchers.Default) { delay(duration) } +} + +/** Delays the current coroutine until the given predicate returns `true`. */ +suspend fun delayUntil(name: String? = null, predicate: () -> Boolean) { + while (!predicate()) { + try { + delayIgnoringTestScheduler(0.2.seconds) + } catch (e: CancellationException) { + throw DelayUntilTimeoutException("delayUntil(name=$name) cancelled") + } + } +} + +/** + * Generates and returns a random UUID in its string format. + * + * The returned string will be a UUID with all dashes removed, because Data Connect will remove the + * dashes before writing the value to the database (see cl/629562890). + */ +fun randomId(): String = UUID.randomUUID().toString().replace("-", "") + +class DelayUntilTimeoutException(message: String) : Exception(message) + +/** + * Calls `Assert.fail()`, but also returns `Nothing` so that the Kotlin compiler can do better type + * deduction for code that follows this `fail()` call. + */ +fun fail(message: String): Nothing { + Assert.fail(message) + throw IllegalStateException("Should never get here") +} + +/** Calls the given block and asserts that it throws the given exception. */ +@Deprecated( + message = "use io.kotest.assertions.throwables.shouldThrow instead", + replaceWith = + ReplaceWith(expression = "shouldThrow {...}", "io.kotest.assertions.throwables.shouldThrow") +) +inline fun T.assertThrows(expectedException: KClass, block: T.() -> R): E = + runCatching { block() } + .fold( + onSuccess = { + fail( + "Expected block to throw ${expectedException.qualifiedName}, " + + "but it did not throw and returned: $it" + ) + }, + onFailure = { + expectedException.safeCast(it) + ?: fail("Expected block to throw ${expectedException.qualifiedName}, but it threw: $it") + } + ) + +/** + * The largest positive integer value that can be represented by a 64-bit double. + * + * Taken from `Number.MAX_SAFE_INTEGER` in JavaScript. + */ +const val MAX_SAFE_INTEGER = 9007199254740991.0 + +/** + * Generates and returns a random, valid string suitable to be the "name" of a [FirebaseApp]. + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "fmfbm74g32"). + */ +fun randomAppName(key: String) = "appName-$key-${Random.nextAlphanumericString(length = 8)}" + +/** + * Generates and returns a random, valid string suitable to be the "applicationId" of a + * [FirebaseApp]. + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "axqm2rajxv"). + */ +fun randomApplicationId(key: String) = "appId-$key-${Random.nextAlphanumericString(length = 8)}" + +/** + * Generates and returns a random, valid string suitable to be the "projectId" of a [FirebaseApp]. + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "ncdd6n863r"). + */ +@Deprecated( + "use Arb.projectId() from Arbs.kt instead", + replaceWith = + ReplaceWith("Arb.projectId(key).next()", "com.google.firebase.dataconnect.testutil.projectId") +) +fun randomProjectId(key: String) = "projId-$key-${Random.nextAlphanumericString(length = 8)}" + +/** + * Generates and returns a random, valid string suitable to be a host name in [DataConnectSettings]. + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "cxncg4zbvb"). + */ +fun randomHost(key: String) = "host.$key.${Random.nextAlphanumericString(length = 8)}" + +/** Generates and returns a boolean value suitable for "sslEnabled". */ +fun randomSslEnabled() = Random.nextBoolean() + +/** + * Generates and returns a new [DataConnectSettings] object with random values. + * @param hostKey A value to specify to [randomHost] (e.g. "wqxhf5apez"). + */ +fun randomDataConnectSettings(hostKey: String) = + DataConnectSettings(host = randomHost(hostKey), sslEnabled = randomSslEnabled()) + +/** + * Generates and returns a random, valid string suitable for a "request ID". + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "9p6dyyr2zp"). + */ +@Deprecated( + "use Arb.requestId() from Arbs.kt instead", + replaceWith = + ReplaceWith("Arb.requestId(key).next()", "com.google.firebase.dataconnect.testutil.requestId") +) +fun randomRequestId(key: String) = "requestId_${key}_${Random.nextAlphanumericString(length = 8)}" + +/** + * Generates and returns a random, valid string suitable for [OperationRef.operationName]. + * @param key A hardcoded random string that will be incorporated into the returned string; useful + * for correlating the application ID with its call site (e.g. "sc4kc7mqba"). + */ +@Deprecated( + "use Arb.requestId() from Arbs.kt instead", + replaceWith = + ReplaceWith( + "Arb.operationName(key).next()", + "com.google.firebase.dataconnect.testutil.requestId" + ) +) +fun randomOperationName(key: String) = + "operation_${key}_${Random.nextAlphanumericString(length = 8)}" + +/** + * Create and return a new [CoroutineScope] that behaves exactly like [TestScope.backgroundScope] + * except that the jobs that it enqueues _are_ advanced by calls to + * [TestCoroutineScheduler.advanceUntilIdle()]. + * + * Normally, coroutines started by [TestScope.backgroundScope] run independently and are _not_ + * advanced by calls to [TestCoroutineScheduler.advanceUntilIdle()]. But sometimes it is _desirable_ + * that background jobs are advanced by [TestCoroutineScheduler.advanceUntilIdle()] yet maintain the + * other qualities of coroutines registered with the `backgroundScope`, such as being automatically + * cancelled upon test completion. + */ +fun TestScope.newBackgroundScopeThatAdvancesLikeForeground(): CoroutineScope { + TestCoroutineScheduler + // Find the `BackgroundWork` coroutine context element and create a new context that is the same + // as the `backgroundScope` context but lacks the `BackgroundWork` element. + val backgroundWorkClass = Class.forName("kotlinx.coroutines.test.BackgroundWork").kotlin + val backgroundContextWithoutBackgroundWork = + backgroundScope.coroutineContext.fold(EmptyCoroutineContext) { + newCoroutineContext, + elem -> + if (elem::class != backgroundWorkClass) { + newCoroutineContext + elem + } else { + newCoroutineContext + } + } + return CoroutineScope( + backgroundContextWithoutBackgroundWork + Job(backgroundContextWithoutBackgroundWork[Job]) + ) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/UnavailableDeferred.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/UnavailableDeferred.kt new file mode 100644 index 00000000000..013f978ba34 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/UnavailableDeferred.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.dataconnect.testutil + +import com.google.firebase.inject.Deferred + +/** An implementation of {@link Deferred} whose provider never becomes available. */ +class UnavailableDeferred : Deferred { + override fun whenAvailable(handler: Deferred.DeferredHandler) {} +} diff --git a/firebase-dataconnect/testutil/src/main/resources/io/mockk/settings.properties b/firebase-dataconnect/testutil/src/main/resources/io/mockk/settings.properties new file mode 100644 index 00000000000..d35f0d18bc1 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/resources/io/mockk/settings.properties @@ -0,0 +1 @@ +stackTracesAlignment=left diff --git a/firebase-dataconnect/testutil/testutil.gradle.kts b/firebase-dataconnect/testutil/testutil.gradle.kts new file mode 100644 index 00000000000..64d2595bcc8 --- /dev/null +++ b/firebase-dataconnect/testutil/testutil.gradle.kts @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.kotlinx.serialization) +} + +android { + val compileSdkVersion : Int by rootProject + val targetSdkVersion : Int by rootProject + val minSdkVersion : Int by rootProject + + namespace = "com.google.firebase.dataconnect.testutil" + compileSdk = compileSdkVersion + defaultConfig { + minSdk = minSdkVersion + targetSdk = targetSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = "1.8" } + + packaging { + resources { + excludes.add("META-INF/LICENSE.md") + excludes.add("META-INF/LICENSE-notice.md") + } + } +} + +dependencies { + implementation(project(":firebase-dataconnect")) + + implementation("com.google.firebase:firebase-components:18.0.0") + implementation("com.google.firebase:firebase-auth:22.3.1") + + implementation(libs.androidx.test.junit) + implementation(libs.kotest.property) + implementation(libs.kotlin.coroutines.test) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.mockk) + implementation(libs.robolectric) + implementation(libs.truth) +} + +tasks.withType().all { + kotlinOptions { + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97a650fbbd1..4aa15b25443 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,24 @@ [versions] # javalite, protoc and protobufjavautil versions should be in sync while updating and -# it needs to match the protobuf version which grpc has transitive dependency on. +# it needs to match the protobuf version which grpc has transitive dependency on, which +# needs to match the version of grpc that grpc-kotlin has a transitive dependency on. android-lint = "31.3.2" autovalue = "1.10.1" -coroutines = "1.6.4" +coroutines = "1.7.3" dagger = "2.43.2" grpc = "1.62.2" +grpcKotlin = "1.4.1" javalite = "3.21.11" kotlin = "1.8.22" +mockk = "1.13.11" serialization-plugin = "1.8.22" protoc = "3.21.11" truth = "1.4.2" robolectric = "4.12" protobufjavautil = "3.21.11" -kotest = "5.5.5" +kotest = "5.9.1" quickcheck = "0.6" +serialization = "1.5.1" androidx-test-core="1.5.0" androidx-test-junit="1.1.5" androidx-test-truth = "1.5.0" @@ -28,22 +32,36 @@ android-lint-testutils = { module = "com.android.tools:testutils", version.ref = androidx-annotation = { module = "androidx.annotation:annotation", version = "1.5.0" } androidx-core = { module = "androidx.core:core", version = "1.2.0" } androidx-futures = { module = "androidx.concurrent:concurrent-futures", version = "1.1.0" } +auth0-jwt = { module = "com.auth0:java-jwt", version = "4.4.0" } autovalue = { module = "com.google.auto.value:auto-value", version.ref = "autovalue" } autovalue-annotations = { module = "com.google.auto.value:auto-value-annotations", version.ref = "autovalue" } dagger-dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } errorprone-annotations = { module = "com.google.errorprone:error_prone_annotations", version = "2.26.0" } findbugs-jsr305 = { module = "com.google.code.findbugs:jsr305", version = "3.0.2" } +grpc-android = { module = "io.grpc:grpc-android", version.ref = "grpc" } +grpc-kotlin-stub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpcKotlin" } +grpc-okhttp = { module = "io.grpc:grpc-okhttp", version.ref = "grpc" } +grpc-protobuf-lite = { module = "io.grpc:grpc-protobuf-lite", version.ref = "grpc" } +grpc-protoc-gen-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc" } +grpc-protoc-gen-kotlin = { module = "io.grpc:protoc-gen-grpc-kotlin", version.ref = "grpcKotlin" } +grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } +javax-annotation-jsr250 = { module = "javax.annotation:jsr250-api", version = "1.0" } javax-inject = { module = "javax.inject:javax.inject", version = "1" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-coroutines-tasks = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.5.1" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "3.12.13" } org-json = { module = "org.json:json", version = "20210307" } playservices-base = { module = "com.google.android.gms:play-services-base", version = "18.1.0" } playservices-basement = { module = "com.google.android.gms:play-services-basement", version = "18.3.0" } playservices-tasks = { module = "com.google.android.gms:play-services-tasks", version = "18.1.0" } +protoc = { module = "com.google.protobuf:protoc", version.ref = "protoc" } +protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "javalite" } +protobuf-java-lite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "javalite" } +protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "javalite" } # Test libs androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } @@ -55,17 +73,21 @@ junit = { module = "junit:junit", version = "4.13.2" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } mockito-core = { module = "org.mockito:mockito-core", version = "5.2.0" } mockito-dexmaker = { module = "com.linkedin.dexmaker:dexmaker-mockito", version = "2.28.3" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +truth-liteproto-extension = { module = "com.google.truth.extensions:truth-liteproto-extension", version.ref = "truth" } protobuf-java-util = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protobufjavautil" } kotest-runner = { module = "io.kotest:kotest-runner-junit4-jvm", version.ref = "kotest" } kotest-assertions = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } kotest-property = { module = "io.kotest:kotest-property-jvm", version.ref = "kotest" } +kotest-property-arbs = { module = "io.kotest.extensions:kotest-property-arbs", version = "2.1.2" } quickcheck = { module = "net.java:quickcheck", version.ref = "quickcheck" } -protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "javalite" } +turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" } [bundles] -kotest = ["kotest-runner", "kotest-assertions", "kotest-property"] +kotest = ["kotest-runner", "kotest-assertions", "kotest-property", "kotest-property-arbs"] playservices = ["playservices-base", "playservices-basement", "playservices-tasks"] [plugins] diff --git a/settings.gradle b/settings.gradle index b52d04ceb36..e32f57e51cc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +pluginManagement { + includeBuild("firebase-dataconnect/gradleplugin") +} + rootProject.name = 'com.google.firebase' //Note: do not add subprojects to this file. Instead add them to subprojects.gradle diff --git a/subprojects.cfg b/subprojects.cfg index 366b1a712fe..3be81de81a1 100644 --- a/subprojects.cfg +++ b/subprojects.cfg @@ -27,6 +27,10 @@ firebase-crashlytics-ndk firebase-database firebase-database:ktx firebase-database-collection +firebase-dataconnect +firebase-dataconnect:androidTestutil +firebase-dataconnect:connectors +firebase-dataconnect:testutil firebase-datatransport firebase-dynamic-links firebase-dynamic-links:ktx