From 5fccb61f3fd55b8deba262b51bb957bec8051021 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Mon, 23 Sep 2024 14:24:26 +0000 Subject: [PATCH 01/18] RemoteConfigComponentTest.java: fix flaky test (#6291) --- .../google/firebase/remoteconfig/RemoteConfigComponentTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java index ebb69b491b6..c6869267a4d 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigComponentTest.java @@ -185,7 +185,7 @@ public void registerRolloutsStateSubscriber_firebaseNamespace_callsSubscriptionH when(mockMetadataClient.getRealtimeBackoffMetadata()) .thenReturn(new ConfigMetadataClient.RealtimeBackoffMetadata(0, new Date())); - RemoteConfigComponent frcComponent = getNewFrcComponent(); + RemoteConfigComponent frcComponent = getNewFrcComponentWithoutLoadingDefault(); FirebaseRemoteConfig instance = getFrcInstanceFromComponent(frcComponent, DEFAULT_NAMESPACE); frcComponent.registerRolloutsStateSubscriber(DEFAULT_NAMESPACE, mockRolloutsStateSubscriber); From f0dd60a2c8a5e4290fd595f98670127801aca54f Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Mon, 23 Sep 2024 11:23:07 -0400 Subject: [PATCH 02/18] Switch `RequestOption` to accept long timeout only (#6289) The `RequestOptions` class only customizable paramter, `timeout`, is now called `timeoutInMillis` and it's type is `Long`. Using `kotlin.time.Duration` in our public API, while convenient, introduces an issue with Java code. In short, `Duration` is exposed as `long` in the JVM, and that long can represent time in milliseconds, or nanoseconds. For clarity, we've fallback to long. --- .../firebase/vertexai/GenerativeModel.kt | 2 +- .../firebase/vertexai/common/APIController.kt | 6 ++- .../vertexai/common/RequestOptions.kt | 46 ------------------- .../vertexai/internal/util/conversions.kt | 4 -- .../firebase/vertexai/type/RequestOptions.kt | 29 ++++++------ .../vertexai/common/APIControllerTests.kt | 5 +- .../firebase/vertexai/common/util/tests.kt | 2 +- .../google/firebase/vertexai/util/tests.kt | 2 +- 8 files changed, 27 insertions(+), 69 deletions(-) delete mode 100644 firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/RequestOptions.kt diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt index 96414185742..311d333e2f2 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt @@ -83,7 +83,7 @@ internal constructor( APIController( apiKey, modelName, - requestOptions.toInternal(), + requestOptions, "gl-kotlin/${KotlinVersion.CURRENT} fire/${BuildConfig.VERSION_NAME}", object : HeaderProvider { override val timeout: Duration diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt index 113573bab1e..f81fa0fc99a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt @@ -21,6 +21,7 @@ import androidx.annotation.VisibleForTesting import com.google.firebase.vertexai.common.server.FinishReason import com.google.firebase.vertexai.common.util.decodeToFlow import com.google.firebase.vertexai.common.util.fullModelName +import com.google.firebase.vertexai.type.RequestOptions import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine @@ -44,7 +45,9 @@ import io.ktor.http.contentType import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteChannel +import kotlin.math.max import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow @@ -115,7 +118,8 @@ internal constructor( HttpClient(httpEngine) { install(HttpTimeout) { requestTimeoutMillis = requestOptions.timeout.inWholeMilliseconds - socketTimeoutMillis = 80_000 + socketTimeoutMillis = + max(180.seconds.inWholeMilliseconds, requestOptions.timeout.inWholeMilliseconds) } install(ContentNegotiation) { json(JSON) } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/RequestOptions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/RequestOptions.kt deleted file mode 100644 index 658c0f65836..00000000000 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/RequestOptions.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.vertexai.common - -import io.ktor.client.plugins.HttpTimeout -import kotlin.time.Duration -import kotlin.time.DurationUnit -import kotlin.time.toDuration - -/** - * Configurable options unique to how requests to the backend are performed. - * - * @property timeout the maximum amount of time for a request to take, from the first request to - * first response. - * @property apiVersion the api endpoint to call. - */ -internal class RequestOptions( - val timeout: Duration, - val apiVersion: String = "v1beta", - val endpoint: String = "https://generativelanguage.googleapis.com", -) { - @JvmOverloads - constructor( - timeout: Long? = HttpTimeout.INFINITE_TIMEOUT_MS, - apiVersion: String = "v1beta", - endpoint: String = "https://generativelanguage.googleapis.com", - ) : this( - (timeout ?: HttpTimeout.INFINITE_TIMEOUT_MS).toDuration(DurationUnit.MILLISECONDS), - apiVersion, - endpoint, - ) -} diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt index 2710501dded..3adf069a0ed 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt @@ -47,7 +47,6 @@ import com.google.firebase.vertexai.type.HarmSeverity import com.google.firebase.vertexai.type.ImagePart import com.google.firebase.vertexai.type.Part import com.google.firebase.vertexai.type.PromptFeedback -import com.google.firebase.vertexai.type.RequestOptions import com.google.firebase.vertexai.type.SafetyRating import com.google.firebase.vertexai.type.SafetySetting import com.google.firebase.vertexai.type.SerializationException @@ -63,9 +62,6 @@ import org.json.JSONObject private const val BASE_64_FLAGS = Base64.NO_WRAP -internal fun RequestOptions.toInternal() = - com.google.firebase.vertexai.common.RequestOptions(timeout, apiVersion, endpoint) - internal fun Content.toInternal() = com.google.firebase.vertexai.common.shared.Content( this.role ?: "user", diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt index 5a7d11f2cf8..0fedcdb5c4f 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt @@ -17,23 +17,26 @@ package com.google.firebase.vertexai.type import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.toDuration -/** - * Configurable options unique to how requests to the backend are performed. - * - * @property timeout the maximum amount of time for a request to take, from the first request to - * first response. - * @property apiVersion the api endpoint to call. - */ -class RequestOptions(val timeout: Duration) { - - internal val endpoint = "https://firebaseml.googleapis.com" - internal val apiVersion = "v2beta" +/** Configurable options unique to how requests to the backend are performed. */ +class RequestOptions +internal constructor( + internal val timeout: Duration, + internal val endpoint: String = "https://firebaseml.googleapis.com", + internal val apiVersion: String = "v2beta", +) { + /** + * Constructor for RequestOptions. + * + * @param timeoutInMillis the maximum amount of time, in milliseconds, for a request to take, from + * the first request to first response. + */ @JvmOverloads constructor( - timeout: Long? = Long.MAX_VALUE, - ) : this((timeout ?: Long.MAX_VALUE).toDuration(DurationUnit.MILLISECONDS)) + timeoutInMillis: Long = 180.seconds.inWholeMilliseconds + ) : this(timeout = timeoutInMillis.toDuration(DurationUnit.MILLISECONDS)) } diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt index b683c1ba742..582678cf306 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt @@ -26,6 +26,7 @@ import com.google.firebase.vertexai.common.util.commonTest import com.google.firebase.vertexai.common.util.createResponses import com.google.firebase.vertexai.common.util.doBlocking import com.google.firebase.vertexai.common.util.prepareStreamingResponse +import com.google.firebase.vertexai.type.RequestOptions import io.kotest.assertions.json.shouldContainJsonKey import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe @@ -107,7 +108,7 @@ internal class RequestFormatTests { } } - mockEngine.requestHistory.first().url.host shouldBe "generativelanguage.googleapis.com" + mockEngine.requestHistory.first().url.host shouldBe "firebaseml.googleapis.com" } @Test @@ -121,7 +122,7 @@ internal class RequestFormatTests { APIController( "super_cool_test_key", "gemini-pro-1.5", - RequestOptions(endpoint = "https://my.custom.endpoint"), + RequestOptions(timeout = 5.seconds, endpoint = "https://my.custom.endpoint"), mockEngine, TEST_CLIENT_ID, null, diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt index dba31e45730..8a7184e9851 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt @@ -22,10 +22,10 @@ import com.google.firebase.vertexai.common.APIController import com.google.firebase.vertexai.common.GenerateContentRequest import com.google.firebase.vertexai.common.GenerateContentResponse import com.google.firebase.vertexai.common.JSON -import com.google.firebase.vertexai.common.RequestOptions import com.google.firebase.vertexai.common.server.Candidate import com.google.firebase.vertexai.common.shared.Content import com.google.firebase.vertexai.common.shared.TextPart +import com.google.firebase.vertexai.type.RequestOptions import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.ktor.http.HttpStatusCode diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt index 37c0581b372..7afb202c1d6 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt @@ -18,7 +18,7 @@ package com.google.firebase.vertexai.util import com.google.firebase.vertexai.GenerativeModel import com.google.firebase.vertexai.common.APIController -import com.google.firebase.vertexai.common.RequestOptions +import com.google.firebase.vertexai.type.RequestOptions import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.ktor.http.HttpStatusCode From 7bc8e518cc84ae43421618a13aaac1264dede6a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 01:30:35 -0400 Subject: [PATCH 03/18] Bump body-parser and express in /smoke-tests/src/androidTest/backend/functions/functions (#6295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [body-parser](https://github.com/expressjs/body-parser) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together. Updates `body-parser` from 1.20.2 to 1.20.3
Release notes

Sourced from body-parser's releases.

1.20.3

What's Changed

Important

  • deps: qs@6.13.0
  • add depth option to customize the depth level in the parser
  • IMPORTANT: The default depth level for parsing URL-encoded data is now 32 (previously was Infinity). Documentation

Other changes

New Contributors

Full Changelog: https://github.com/expressjs/body-parser/compare/1.20.2...1.20.3

Changelog

Sourced from body-parser's changelog.

1.20.3 / 2024-09-10

  • deps: qs@6.13.0
  • add depth option to customize the depth level in the parser
  • IMPORTANT: The default depth level for parsing URL-encoded data is now 32 (previously was Infinity)
Commits
Maintainer changes

This version was pushed to npm by ulisesgascon, a new releaser for body-parser since your current version.


Updates `express` from 4.19.2 to 4.21.0
Release notes

Sourced from express's releases.

4.21.0

What's Changed

New Contributors

Full Changelog: https://github.com/expressjs/express/compare/4.20.0...4.21.0

4.20.0

What's Changed

Important

  • IMPORTANT: The default depth level for parsing URL-encoded data is now 32 (previously was Infinity)
  • Remove link renderization in html while using res.redirect

Other Changes

... (truncated)

Changelog

Sourced from express's changelog.

4.21.0 / 2024-09-11

  • Deprecate res.location("back") and res.redirect("back") magic string
  • deps: serve-static@1.16.2
    • includes send@0.19.0
  • deps: finalhandler@1.3.1
  • deps: qs@6.13.0

4.20.0 / 2024-09-10

  • deps: serve-static@0.16.0
    • Remove link renderization in html while redirecting
  • deps: send@0.19.0
    • Remove link renderization in html while redirecting
  • deps: body-parser@0.6.0
    • add depth option to customize the depth level in the parser
    • IMPORTANT: The default depth level for parsing URL-encoded data is now 32 (previously was Infinity)
  • Remove link renderization in html while using res.redirect
  • deps: path-to-regexp@0.1.10
    • Adds support for named matching groups in the routes using a regex
    • Adds backtracking protection to parameters without regexes defined
  • deps: encodeurl@~2.0.0
    • Removes encoding of \, |, and ^ to align better with URL spec
  • Deprecate passing options.maxAge and options.expires to res.clearCookie
    • Will be ignored in v5, clearCookie will set a cookie with an expires in the past to instruct clients to delete the cookie
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/firebase/firebase-android-sdk/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Rodrigo Lazo --- .../functions/functions/package-lock.json | 191 ++++++++++-------- 1 file changed, 105 insertions(+), 86 deletions(-) diff --git a/smoke-tests/src/androidTest/backend/functions/functions/package-lock.json b/smoke-tests/src/androidTest/backend/functions/functions/package-lock.json index 217531618a5..fab7355b419 100644 --- a/smoke-tests/src/androidTest/backend/functions/functions/package-lock.json +++ b/smoke-tests/src/androidTest/backend/functions/functions/package-lock.json @@ -741,9 +741,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -753,7 +753,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -1070,9 +1070,9 @@ "devOptional": true }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -1354,36 +1354,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -1477,12 +1477,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -2249,9 +2249,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/methods": { "version": "1.1.2", @@ -2368,9 +2371,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2466,9 +2472,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -2550,11 +2556,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -2715,9 +2721,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -2750,6 +2756,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -2767,14 +2781,14 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -3905,9 +3919,9 @@ "optional": true }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -3917,7 +3931,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -4160,9 +4174,9 @@ "devOptional": true }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -4372,36 +4386,36 @@ "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -4475,12 +4489,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -5088,9 +5102,9 @@ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "methods": { "version": "1.1.2", @@ -5166,9 +5180,9 @@ "optional": true }, "object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "on-finished": { "version": "2.4.1", @@ -5237,9 +5251,9 @@ "dev": true }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "prelude-ls": { "version": "1.2.1", @@ -5302,11 +5316,11 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "range-parser": { @@ -5405,9 +5419,9 @@ } }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -5439,6 +5453,11 @@ } } }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -5452,14 +5471,14 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-function-length": { From d3e75a943f273f5ce63c318214450c01e74b27d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Tue, 24 Sep 2024 11:31:40 -0400 Subject: [PATCH 04/18] Move requestOptions to be the last positional argument (#6292) b/368716151 --- .../kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt index 41a1437834b..c19326a1682 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt @@ -55,10 +55,10 @@ internal constructor( modelName: String, generationConfig: GenerationConfig? = null, safetySettings: List? = null, - requestOptions: RequestOptions = RequestOptions(), tools: List? = null, toolConfig: ToolConfig? = null, systemInstruction: Content? = null, + requestOptions: RequestOptions = RequestOptions(), ): GenerativeModel { if (location.trim().isEmpty() || location.contains("/")) { throw InvalidLocationException(location) From bd157db112a2e0023882e0c104ac13dd77d60652 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Tue, 24 Sep 2024 11:32:04 -0400 Subject: [PATCH 05/18] Several small changes to enums API (#6294) The changes restricted to enum values and names are: - Remove `UNSPECIFIED` from `FinishReason` - Renamed `BlockThreshold` to `HarmBlockThreshold` - Rename `UNSPECIFIED` to `UNKNOWN` in `HarmBlockThreshold` - Remove `UNSPECIFIED` from `HarmProbability` - Remove `UNSPECIFIED` from `HarmSeverity` - Remove `UNSPECIFIED` from `BlockReason` Additionally, additional changes to non-enum values include - Make `totalBillableCharacters` nullable and optional b/367308641 --- .../vertexai/internal/util/conversions.kt | 33 +++++++++---------- .../firebase/vertexai/type/Candidate.kt | 3 -- .../vertexai/type/CountTokensResponse.kt | 5 +-- ...lockThreshold.kt => HarmBlockThreshold.kt} | 5 +-- .../firebase/vertexai/type/HarmProbability.kt | 3 -- .../firebase/vertexai/type/HarmSeverity.kt | 3 -- .../firebase/vertexai/type/PromptFeedback.kt | 3 -- .../firebase/vertexai/type/SafetySetting.kt | 5 +-- 8 files changed, 22 insertions(+), 38 deletions(-) rename firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/{BlockThreshold.kt => HarmBlockThreshold.kt} (92%) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt index 3adf069a0ed..506570d96e1 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt @@ -26,10 +26,8 @@ import com.google.firebase.vertexai.common.shared.FunctionCall import com.google.firebase.vertexai.common.shared.FunctionCallPart import com.google.firebase.vertexai.common.shared.FunctionResponse import com.google.firebase.vertexai.common.shared.FunctionResponsePart -import com.google.firebase.vertexai.common.shared.HarmBlockThreshold import com.google.firebase.vertexai.type.BlobPart import com.google.firebase.vertexai.type.BlockReason -import com.google.firebase.vertexai.type.BlockThreshold import com.google.firebase.vertexai.type.Candidate import com.google.firebase.vertexai.type.Citation import com.google.firebase.vertexai.type.CitationMetadata @@ -41,6 +39,7 @@ import com.google.firebase.vertexai.type.FunctionCallingConfig import com.google.firebase.vertexai.type.FunctionDeclaration import com.google.firebase.vertexai.type.GenerateContentResponse import com.google.firebase.vertexai.type.GenerationConfig +import com.google.firebase.vertexai.type.HarmBlockThreshold import com.google.firebase.vertexai.type.HarmCategory import com.google.firebase.vertexai.type.HarmProbability import com.google.firebase.vertexai.type.HarmSeverity @@ -138,13 +137,16 @@ internal fun ToolConfig.toInternal() = ) ) -internal fun BlockThreshold.toInternal() = +internal fun HarmBlockThreshold.toInternal() = when (this) { - BlockThreshold.NONE -> HarmBlockThreshold.BLOCK_NONE - BlockThreshold.ONLY_HIGH -> HarmBlockThreshold.BLOCK_ONLY_HIGH - BlockThreshold.MEDIUM_AND_ABOVE -> HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE - BlockThreshold.LOW_AND_ABOVE -> HarmBlockThreshold.BLOCK_LOW_AND_ABOVE - BlockThreshold.UNSPECIFIED -> HarmBlockThreshold.UNSPECIFIED + HarmBlockThreshold.NONE -> + com.google.firebase.vertexai.common.shared.HarmBlockThreshold.BLOCK_NONE + HarmBlockThreshold.ONLY_HIGH -> + com.google.firebase.vertexai.common.shared.HarmBlockThreshold.BLOCK_ONLY_HIGH + HarmBlockThreshold.MEDIUM_AND_ABOVE -> + com.google.firebase.vertexai.common.shared.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE + HarmBlockThreshold.LOW_AND_ABOVE -> + com.google.firebase.vertexai.common.shared.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE } internal fun Tool.toInternal() = @@ -258,8 +260,7 @@ internal fun com.google.firebase.vertexai.common.server.FinishReason?.toPublic() com.google.firebase.vertexai.common.server.FinishReason.SAFETY -> FinishReason.SAFETY com.google.firebase.vertexai.common.server.FinishReason.STOP -> FinishReason.STOP com.google.firebase.vertexai.common.server.FinishReason.OTHER -> FinishReason.OTHER - com.google.firebase.vertexai.common.server.FinishReason.UNSPECIFIED -> FinishReason.UNSPECIFIED - com.google.firebase.vertexai.common.server.FinishReason.UNKNOWN -> FinishReason.UNKNOWN + else -> FinishReason.UNKNOWN } internal fun com.google.firebase.vertexai.common.shared.HarmCategory.toPublic() = @@ -270,7 +271,7 @@ internal fun com.google.firebase.vertexai.common.shared.HarmCategory.toPublic() HarmCategory.SEXUALLY_EXPLICIT com.google.firebase.vertexai.common.shared.HarmCategory.DANGEROUS_CONTENT -> HarmCategory.DANGEROUS_CONTENT - com.google.firebase.vertexai.common.shared.HarmCategory.UNKNOWN -> HarmCategory.UNKNOWN + else -> HarmCategory.UNKNOWN } internal fun com.google.firebase.vertexai.common.server.HarmProbability.toPublic() = @@ -280,9 +281,7 @@ internal fun com.google.firebase.vertexai.common.server.HarmProbability.toPublic com.google.firebase.vertexai.common.server.HarmProbability.LOW -> HarmProbability.LOW com.google.firebase.vertexai.common.server.HarmProbability.NEGLIGIBLE -> HarmProbability.NEGLIGIBLE - com.google.firebase.vertexai.common.server.HarmProbability.UNSPECIFIED -> - HarmProbability.UNSPECIFIED - com.google.firebase.vertexai.common.server.HarmProbability.UNKNOWN -> HarmProbability.UNKNOWN + else -> HarmProbability.UNKNOWN } internal fun com.google.firebase.vertexai.common.server.HarmSeverity.toPublic() = @@ -291,16 +290,14 @@ internal fun com.google.firebase.vertexai.common.server.HarmSeverity.toPublic() com.google.firebase.vertexai.common.server.HarmSeverity.MEDIUM -> HarmSeverity.MEDIUM com.google.firebase.vertexai.common.server.HarmSeverity.LOW -> HarmSeverity.LOW com.google.firebase.vertexai.common.server.HarmSeverity.NEGLIGIBLE -> HarmSeverity.NEGLIGIBLE - com.google.firebase.vertexai.common.server.HarmSeverity.UNSPECIFIED -> HarmSeverity.UNSPECIFIED - com.google.firebase.vertexai.common.server.HarmSeverity.UNKNOWN -> HarmSeverity.UNKNOWN + else -> HarmSeverity.UNKNOWN } internal fun com.google.firebase.vertexai.common.server.BlockReason.toPublic() = when (this) { - com.google.firebase.vertexai.common.server.BlockReason.UNSPECIFIED -> BlockReason.UNSPECIFIED com.google.firebase.vertexai.common.server.BlockReason.SAFETY -> BlockReason.SAFETY com.google.firebase.vertexai.common.server.BlockReason.OTHER -> BlockReason.OTHER - com.google.firebase.vertexai.common.server.BlockReason.UNKNOWN -> BlockReason.UNKNOWN + else -> BlockReason.UNKNOWN } internal fun com.google.firebase.vertexai.common.GenerateContentResponse.toPublic(): diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt index 1a23885cbe0..68eba03f4c0 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt @@ -68,9 +68,6 @@ enum class FinishReason { /** A new and not yet supported value. */ UNKNOWN, - /** Reason is unspecified. */ - UNSPECIFIED, - /** Model finished successfully and stopped. */ STOP, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt index 95eac64958f..ac4ee804715 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt @@ -20,9 +20,10 @@ package com.google.firebase.vertexai.type * Represents a response measuring model input. * * @property totalTokens A count of the tokens in the input - * @property totalBillableCharacters A count of the characters that are billable in the input + * @property totalBillableCharacters A count of the characters that are billable in the input, if + * available. */ -class CountTokensResponse(val totalTokens: Int, val totalBillableCharacters: Int) { +class CountTokensResponse(val totalTokens: Int, val totalBillableCharacters: Int? = null) { operator fun component1() = totalTokens operator fun component2() = totalBillableCharacters diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/BlockThreshold.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt similarity index 92% rename from firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/BlockThreshold.kt rename to firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt index 4d22d03981d..ade2d1a9513 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/BlockThreshold.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt @@ -19,10 +19,7 @@ package com.google.firebase.vertexai.type /** * Represents the threshold for some [HarmCategory] that is allowed and blocked by [SafetySetting]. */ -enum class BlockThreshold { - /** The threshold was not specified. */ - UNSPECIFIED, - +enum class HarmBlockThreshold { /** Content with negligible harm is allowed. */ LOW_AND_ABOVE, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt index 1a08c2ec39e..56ab86e4707 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt @@ -21,9 +21,6 @@ enum class HarmProbability { /** A new and not yet supported value. */ UNKNOWN, - /** Probability for harm is unspecified. */ - UNSPECIFIED, - /** Probability for harm is negligible. */ NEGLIGIBLE, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt index a5ae583b357..8e3f0d37c9a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt @@ -21,9 +21,6 @@ enum class HarmSeverity { /** A new and not yet supported value. */ UNKNOWN, - /** Severity for harm is unspecified. */ - UNSPECIFIED, - /** Severity for harm is negligible. */ NEGLIGIBLE, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt index f9d2a134628..3043dd01632 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt @@ -34,9 +34,6 @@ enum class BlockReason { /** A new and not yet supported value. */ UNKNOWN, - /** Content was blocked for an unspecified reason. */ - UNSPECIFIED, - /** Content was blocked for violating provided [SafetySetting]. */ SAFETY, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt index 2ca039c46bf..afc881cd033 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt @@ -17,9 +17,10 @@ package com.google.firebase.vertexai.type /** - * A configuration for a [BlockThreshold] of some [HarmCategory] allowed and blocked in responses. + * A configuration for a [HarmBlockThreshold] of some [HarmCategory] allowed and blocked in + * responses. * * @param harmCategory The relevant [HarmCategory]. * @param threshold The threshold form harm allowable. */ -class SafetySetting(val harmCategory: HarmCategory, val threshold: BlockThreshold) {} +class SafetySetting(val harmCategory: HarmCategory, val threshold: HarmBlockThreshold) {} From 6731b5521bbd181c014fd70dd2a992eb6be9a96c Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Tue, 24 Sep 2024 12:23:34 -0400 Subject: [PATCH 06/18] Add missing `FirstOrdinalSerializer` to enum (#6301) It's only necessary on enums we receive from the backend, not from the ones we send. --- .../firebase/vertexai/common/server/Types.kt | 5 +++- .../firebase/vertexai/common/shared/Types.kt | 19 --------------- .../vertexai/common/UnarySnapshotTests.kt | 24 ------------------- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt index 16f96796bbf..cf5f98593e8 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt @@ -31,6 +31,9 @@ internal object BlockReasonSerializer : internal object HarmProbabilitySerializer : KSerializer by FirstOrdinalSerializer(HarmProbability::class) +internal object HarmSeveritySerializer : + KSerializer by FirstOrdinalSerializer(HarmSeverity::class) + internal object FinishReasonSerializer : KSerializer by FirstOrdinalSerializer(FinishReason::class) @@ -117,7 +120,7 @@ internal enum class HarmProbability { HIGH } -@Serializable +@Serializable(HarmSeveritySerializer::class) internal enum class HarmSeverity { UNKNOWN, @SerialName("HARM_SEVERITY_UNSPECIFIED") UNSPECIFIED, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt index ee630cb69ce..ffc077b5d50 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt @@ -58,11 +58,6 @@ internal data class Content(@EncodeDefault val role: String? = "user", val parts @Serializable internal data class FunctionResponsePart(val functionResponse: FunctionResponse) : Part -@Serializable internal data class ExecutableCodePart(val executableCode: ExecutableCode) : Part - -@Serializable -internal data class CodeExecutionResultPart(val codeExecutionResult: CodeExecutionResult) : Part - @Serializable internal data class FunctionResponse(val name: String, val response: JsonObject) @Serializable @@ -80,18 +75,6 @@ internal data class FileData( @Serializable internal data class Blob(@SerialName("mime_type") val mimeType: String, val data: Base64) -@Serializable internal data class ExecutableCode(val language: String, val code: String) - -@Serializable internal data class CodeExecutionResult(val outcome: Outcome, val output: String) - -@Serializable -internal enum class Outcome { - @SerialName("OUTCOME_UNSPECIFIED") UNSPECIFIED, - OUTCOME_OK, - OUTCOME_FAILED, - OUTCOME_DEADLINE_EXCEEDED, -} - @Serializable internal data class SafetySetting( val category: HarmCategory, @@ -124,8 +107,6 @@ internal object PartSerializer : JsonContentPolymorphicSerializer(Part::cl "functionResponse" in jsonObject -> FunctionResponsePart.serializer() "inlineData" in jsonObject -> BlobPart.serializer() "fileData" in jsonObject -> FileDataPart.serializer() - "executableCode" in jsonObject -> ExecutableCodePart.serializer() - "codeExecutionResult" in jsonObject -> CodeExecutionResultPart.serializer() else -> throw SerializationException("Unknown Part type") } } diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/UnarySnapshotTests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/UnarySnapshotTests.kt index 77d41bba957..b08eb104248 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/UnarySnapshotTests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/UnarySnapshotTests.kt @@ -20,13 +20,8 @@ import com.google.firebase.vertexai.common.server.BlockReason import com.google.firebase.vertexai.common.server.FinishReason import com.google.firebase.vertexai.common.server.HarmProbability import com.google.firebase.vertexai.common.server.HarmSeverity -import com.google.firebase.vertexai.common.shared.CodeExecutionResult -import com.google.firebase.vertexai.common.shared.CodeExecutionResultPart -import com.google.firebase.vertexai.common.shared.ExecutableCode -import com.google.firebase.vertexai.common.shared.ExecutableCodePart import com.google.firebase.vertexai.common.shared.FunctionCallPart import com.google.firebase.vertexai.common.shared.HarmCategory -import com.google.firebase.vertexai.common.shared.Outcome import com.google.firebase.vertexai.common.shared.TextPart import com.google.firebase.vertexai.common.util.goldenUnaryFile import com.google.firebase.vertexai.common.util.shouldNotBeNullOrEmpty @@ -352,23 +347,4 @@ internal class UnarySnapshotTests { callPart.functionCall.args shouldBe null } } - - @Test - fun `code execution parses correctly`() = - goldenUnaryFile("success-code-execution.json") { - withTimeout(testTimeout) { - val response = apiController.generateContent(textGenerateContentRequest("prompt")) - val content = response.candidates.shouldNotBeNullOrEmpty().first().content - content.shouldNotBeNull() - val executableCodePart = content.parts[0] - val codeExecutionResult = content.parts[1] - - executableCodePart.shouldBe( - ExecutableCodePart(ExecutableCode("PYTHON", "print(\"Hello World\")")) - ) - codeExecutionResult.shouldBe( - CodeExecutionResultPart(CodeExecutionResult(Outcome.OUTCOME_OK, "Hello World")) - ) - } - } } From fe9d61981bad5580e7ce11eeca1e7d4eb24f03f5 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 24 Sep 2024 09:31:00 -0700 Subject: [PATCH 07/18] =?UTF-8?q?remove=20javax.annotation.Nonnull=20from?= =?UTF-8?q?=20files=20containing=20a=20different=20Non=E2=80=A6=20(#6141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …Null annotation Co-authored-by: David Motsonashvili --- .../bandwagoner/RealtimeFragment.java | 2 +- .../remoteconfig/ConfigUpdateListener.java | 3 +-- .../FirebaseRemoteConfigServerException.java | 5 ++--- .../FirebaseRemoteConfigTest.java | 4 ++-- .../firestore/AggregateQuerySnapshot.java | 21 +++++++++---------- .../local/MemoryRemoteDocumentCache.java | 5 ++--- .../model/ProtoMarshallerClient.java | 13 ++++++------ 7 files changed, 24 insertions(+), 29 deletions(-) diff --git a/firebase-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/RealtimeFragment.java b/firebase-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/RealtimeFragment.java index 2b08a7607c1..781324b6444 100644 --- a/firebase-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/RealtimeFragment.java +++ b/firebase-config/bandwagoner/src/main/java/com/googletest/firebase/remoteconfig/bandwagoner/RealtimeFragment.java @@ -94,7 +94,7 @@ private void toggleRealtime(View view, Boolean isChecked) { frc.addOnConfigUpdateListener( new ConfigUpdateListener() { @Override - public void onUpdate(ConfigUpdate configUpdate) { + public void onUpdate(@NonNull ConfigUpdate configUpdate) { Log.d(TAG, String.join(", ", configUpdate.getUpdatedKeys())); updatedParamsText.setText(String.join(", ", configUpdate.getUpdatedKeys())); } diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/ConfigUpdateListener.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/ConfigUpdateListener.java index 9300963a1d6..7389c5aad86 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/ConfigUpdateListener.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/ConfigUpdateListener.java @@ -15,7 +15,6 @@ package com.google.firebase.remoteconfig; import androidx.annotation.NonNull; -import javax.annotation.Nonnull; /** * Event listener interface for real-time Remote Config updates. Implement {@code @@ -38,5 +37,5 @@ public interface ConfigUpdateListener { * * @param error A {@link FirebaseRemoteConfigException} with information about the error. */ - void onError(@Nonnull FirebaseRemoteConfigException error); + void onError(@NonNull FirebaseRemoteConfigException error); } diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java index c2e559358b6..bde7d2f2d1a 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigServerException.java @@ -16,7 +16,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import javax.annotation.Nonnull; /** * A Firebase Remote Config internal issue caused by an interaction with the Firebase Remote Config @@ -46,7 +45,7 @@ public FirebaseRemoteConfigServerException( * Creates a Firebase Remote Config server exception with the given message and {@code * FirebaseRemoteConfigException} code. */ - public FirebaseRemoteConfigServerException(@NonNull String detailMessage, @Nonnull Code code) { + public FirebaseRemoteConfigServerException(@NonNull String detailMessage, @NonNull Code code) { super(detailMessage, code); this.httpStatusCode = -1; } @@ -56,7 +55,7 @@ public FirebaseRemoteConfigServerException(@NonNull String detailMessage, @Nonnu * {@code FirebaseRemoteConfigException} code. */ public FirebaseRemoteConfigServerException( - int httpStatusCode, @NonNull String detailMessage, @Nonnull Code code) { + int httpStatusCode, @NonNull String detailMessage, @NonNull Code code) { super(detailMessage, code); this.httpStatusCode = httpStatusCode; } diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java index 3618fd0ea27..81f292a4c2b 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java @@ -316,7 +316,7 @@ public void setUp() throws Exception { ConfigUpdateListener listener = new ConfigUpdateListener() { @Override - public void onUpdate(ConfigUpdate configUpdate) { + public void onUpdate(@NonNull ConfigUpdate configUpdate) { mockOnUpdateListener.onUpdate(configUpdate); } @@ -1757,7 +1757,7 @@ private void flushScheduledTasks() throws InterruptedException { private ConfigUpdateListener generateEmptyRealtimeListener() { return new ConfigUpdateListener() { @Override - public void onUpdate(ConfigUpdate configUpdate) {} + public void onUpdate(@NonNull ConfigUpdate configUpdate) {} @Override public void onError(@NonNull FirebaseRemoteConfigException error) {} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java index 8f1ce3985c1..a5452199e0f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java @@ -22,7 +22,6 @@ import java.util.Collections; import java.util.Map; import java.util.Objects; -import javax.annotation.Nonnull; /** * The results of executing an {@link AggregateQuery}. @@ -33,9 +32,9 @@ */ public class AggregateQuerySnapshot { - @Nonnull private final AggregateQuery query; + @NonNull private final AggregateQuery query; - @Nonnull private final Map data; + @NonNull private final Map data; AggregateQuerySnapshot(@NonNull AggregateQuery query, @NonNull Map data) { checkNotNull(query); @@ -73,7 +72,7 @@ public long getCount() { * @return The result of the given aggregation. */ @Nullable - public Object get(@Nonnull AggregateField aggregateField) { + public Object get(@NonNull AggregateField aggregateField) { return getInternal(aggregateField); } @@ -83,7 +82,7 @@ public Object get(@Nonnull AggregateField aggregateField) { * @param countAggregateField The count aggregation for which the value is requested. * @return The result of the given count aggregation. */ - public long get(@Nonnull AggregateField.CountAggregateField countAggregateField) { + public long get(@NonNull AggregateField.CountAggregateField countAggregateField) { Long value = getLong(countAggregateField); if (value == null) { throw new IllegalArgumentException( @@ -102,7 +101,7 @@ public long get(@Nonnull AggregateField.CountAggregateField countAggregateField) * @return The result of the given average aggregation. */ @Nullable - public Double get(@Nonnull AggregateField.AverageAggregateField averageAggregateField) { + public Double get(@NonNull AggregateField.AverageAggregateField averageAggregateField) { return getDouble(averageAggregateField); } @@ -116,7 +115,7 @@ public Double get(@Nonnull AggregateField.AverageAggregateField averageAggregate * @return The result of the given average aggregation as a double. */ @Nullable - public Double getDouble(@Nonnull AggregateField aggregateField) { + public Double getDouble(@NonNull AggregateField aggregateField) { Number val = getTypedValue(aggregateField, Number.class); return val != null ? val.doubleValue() : null; } @@ -130,13 +129,13 @@ public Double getDouble(@Nonnull AggregateField aggregateField) { * @return The result of the given average aggregation as a long. */ @Nullable - public Long getLong(@Nonnull AggregateField aggregateField) { + public Long getLong(@NonNull AggregateField aggregateField) { Number val = getTypedValue(aggregateField, Number.class); return val != null ? val.longValue() : null; } @Nullable - private Object getInternal(@Nonnull AggregateField aggregateField) { + private Object getInternal(@NonNull AggregateField aggregateField) { if (!data.containsKey(aggregateField.getAlias())) { throw new IllegalArgumentException( "'" @@ -154,14 +153,14 @@ private Object getInternal(@Nonnull AggregateField aggregateField) { } @Nullable - private T getTypedValue(@Nonnull AggregateField aggregateField, Class clazz) { + private T getTypedValue(@NonNull AggregateField aggregateField, Class clazz) { Object value = getInternal(aggregateField); return castTypedValue(value, aggregateField, clazz); } @Nullable private T castTypedValue( - Object value, @Nonnull AggregateField aggregateField, Class clazz) { + Object value, @NonNull AggregateField aggregateField, Class clazz) { if (value == null) { return null; } else if (!clazz.isInstance(value)) { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java index 6bd043f6e82..36f028f4309 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/MemoryRemoteDocumentCache.java @@ -31,7 +31,6 @@ import java.util.Iterator; import java.util.Map; import java.util.Set; -import javax.annotation.Nonnull; /** In-memory cache of remote documents. */ final class MemoryRemoteDocumentCache implements RemoteDocumentCache { @@ -101,7 +100,7 @@ public Map getAll( public Map getDocumentsMatchingQuery( Query query, IndexOffset offset, - @Nonnull Set mutatedKeys, + @NonNull Set mutatedKeys, @Nullable QueryContext context) { Map result = new HashMap<>(); @@ -142,7 +141,7 @@ public Map getDocumentsMatchingQuery( @Override public Map getDocumentsMatchingQuery( - Query query, IndexOffset offset, @Nonnull Set mutatedKeys) { + Query query, IndexOffset offset, @NonNull Set mutatedKeys) { return getDocumentsMatchingQuery(query, offset, mutatedKeys, /*context*/ null); } diff --git a/firebase-inappmessaging/src/main/java/com/google/firebase/inappmessaging/model/ProtoMarshallerClient.java b/firebase-inappmessaging/src/main/java/com/google/firebase/inappmessaging/model/ProtoMarshallerClient.java index b16b6213171..2ea790eebce 100644 --- a/firebase-inappmessaging/src/main/java/com/google/firebase/inappmessaging/model/ProtoMarshallerClient.java +++ b/firebase-inappmessaging/src/main/java/com/google/firebase/inappmessaging/model/ProtoMarshallerClient.java @@ -16,12 +16,11 @@ import android.text.TextUtils; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.common.base.Preconditions; import com.google.firebase.inappmessaging.MessagesProto; import com.google.firebase.inappmessaging.internal.Logging; import java.util.Map; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; import javax.inject.Inject; import javax.inject.Singleton; @@ -38,7 +37,7 @@ public class ProtoMarshallerClient { @Inject ProtoMarshallerClient() {} - @Nonnull + @NonNull private static ModalMessage.Builder from(MessagesProto.ModalMessage in) { ModalMessage.Builder builder = ModalMessage.builder(); @@ -65,7 +64,7 @@ private static ModalMessage.Builder from(MessagesProto.ModalMessage in) { return builder; } - @Nonnull + @NonNull private static ImageOnlyMessage.Builder from(MessagesProto.ImageOnlyMessage in) { ImageOnlyMessage.Builder builder = ImageOnlyMessage.builder(); @@ -80,7 +79,7 @@ private static ImageOnlyMessage.Builder from(MessagesProto.ImageOnlyMessage in) return builder; } - @Nonnull + @NonNull private static BannerMessage.Builder from(MessagesProto.BannerMessage in) { BannerMessage.Builder builder = BannerMessage.builder(); @@ -107,7 +106,7 @@ private static BannerMessage.Builder from(MessagesProto.BannerMessage in) { return builder; } - @Nonnull + @NonNull private static CardMessage.Builder from(MessagesProto.CardMessage in) { CardMessage.Builder builder = CardMessage.builder(); @@ -207,7 +206,7 @@ private static Text decode(MessagesProto.Text in) { /** Tranform {@link MessagesProto.Content} proto to an {@link InAppMessage} value object */ public static InAppMessage decode( - @Nonnull MessagesProto.Content in, + @NonNull MessagesProto.Content in, @NonNull String campaignId, @NonNull String campaignName, boolean isTestMessage, From 2f851c7d023a7e37e0a2545dd2387872f3537f1c Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Tue, 24 Sep 2024 14:31:02 -0400 Subject: [PATCH 08/18] Create the function declaration schema at construction time (#6302) By creating the schema earlier, any errors when declaring the schema will be caught at the declaration site, which makes debugging much easier. --- .../firebase/vertexai/internal/util/conversions.kt | 11 +---------- .../firebase/vertexai/type/FunctionDeclaration.kt | 5 ++++- .../com/google/firebase/vertexai/type/Schema.kt | 10 ++++++++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt index 506570d96e1..4c30459a6c4 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt @@ -153,16 +153,7 @@ internal fun Tool.toInternal() = com.google.firebase.vertexai.common.client.Tool(functionDeclarations.map { it.toInternal() }) internal fun FunctionDeclaration.toInternal() = - com.google.firebase.vertexai.common.client.FunctionDeclaration( - name, - description, - Schema( - properties = parameters.mapValues { it.value.toInternal() }, - required = parameters.keys.minus(optionalParameters.toSet()).toList(), - type = "OBJECT", - nullable = false, - ), - ) + com.google.firebase.vertexai.common.client.FunctionDeclaration(name, "", schema.toInternal()) internal fun com.google.firebase.vertexai.type.Schema.toInternal(): Schema = Schema( diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt index e8949a4a6ba..f1c0bbd0090 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt @@ -41,4 +41,7 @@ class FunctionDeclaration( val description: String, val parameters: Map, val optionalParameters: List = emptyList(), -) +) { + internal val schema: Schema = + Schema.obj(properties = parameters, optionalProperties = optionalParameters, nullable = false) +} diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt index 4a3383e5c79..4d041c917d2 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt @@ -130,14 +130,20 @@ internal constructor( optionalProperties: List = emptyList(), description: String? = null, nullable: Boolean = false, - ) = - Schema( + ): Schema { + if (!properties.keys.containsAll(optionalProperties)) { + throw IllegalArgumentException( + "All optional properties must be present in properties. Missing: ${optionalProperties.minus(properties.keys)}" + ) + } + return Schema( description = description, nullable = nullable, properties = properties, required = properties.keys.minus(optionalProperties.toSet()).toList(), type = "OBJECT", ) + } /** * Returns a schema for an array. From a95da3f98b29c07a3ac0ba6bb9e862cc07c978ed Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Wed, 25 Sep 2024 16:17:42 +0000 Subject: [PATCH 09/18] firebase-dataconnect merge into main branch (#6290) Includes contributions by @cherylEnkidu --------- Co-authored-by: cherylEnkidu <96084918+cherylEnkidu@users.noreply.github.com> Co-authored-by: rachelsaunders <52258509+rachelsaunders@users.noreply.github.com> --- firebase-dataconnect/.gitignore | 1 + firebase-dataconnect/CHANGELOG.md | 17 + firebase-dataconnect/README.md | 110 ++ .../androidTestutil.gradle.kts | 73 + .../src/main/AndroidManifest.xml | 4 + .../firebase-admin-service-account.key.json | 13 + .../testutil/DataConnectBackend.kt | 276 ++++ .../DataConnectIntegrationTestBase.kt | 81 + .../testutil/FirebaseAuthBackend.kt | 45 + .../testutil/InstrumentationUtils.kt | 46 + .../testutil/TestAppCheckProvider.kt | 458 ++++++ .../testutil/TestDataConnectFactory.kt | 77 + .../testutil/TestFirebaseAppFactory.kt | 42 + .../dataconnect/testutil/TurbineUtils.kt | 30 + .../src/test/AndroidManifest.xml | 4 + .../testutil/DataConnectBackendUnitTest.kt | 133 ++ firebase-dataconnect/api.txt | 293 ++++ .../connectors/connectors.gradle.kts | 111 ++ .../PostsConnectorIntegrationTest.kt | 318 ++++ .../demo/AnyScalarIntegrationTest.kt | 1033 ++++++++++++ .../demo/DateScalarIntegrationTest.kt | 450 ++++++ .../demo/DemoConnectorIntegrationTest.kt | 120 ++ .../demo/KeyVariablesIntegrationTest.kt | 273 ++++ .../ListVariablesAndDataIntegrationTest.kt | 711 +++++++++ .../demo/NestedStructsIntegrationTest.kt | 163 ++ .../demo/NoVariablesIntegrationTest.kt | 44 + .../demo/OnAndViaRelationsIntegrationTest.kt | 152 ++ .../demo/OperationBasicsIntegrationTest.kt | 139 ++ .../demo/OperationExecuteIntegrationTest.kt | 310 ++++ .../demo/OptionalArgumentsIntegrationTest.kt | 51 + .../ScalarVariablesAndDataIntegrationTest.kt | 1111 +++++++++++++ .../demo/SyntheticIdIntegrationTest.kt | 37 + .../demo/TimestampScalarIntegrationTest.kt | 898 +++++++++++ .../DemoConnectorIntegrationTestBase.kt | 29 + .../demo/testutil/DemoConnectorTruth.kt | 123 ++ .../demo/testutil/TestDemoConnectorFactory.kt | 38 + .../KeywordsConnectorIntegrationTest.kt | 150 ++ .../keywords/TestKeywordsConnectorFactory.kt | 38 + .../testutil/TestConnectorFactory.kt | 61 + .../connectors/src/main/AndroidManifest.xml | 4 + .../dataconnect/connectors/CreateComment.kt | 53 + .../dataconnect/connectors/CreatePost.kt | 50 + .../dataconnect/connectors/GetPost.kt | 64 + .../dataconnect/connectors/PostsConnector.kt | 50 + .../connectors/src/test/AndroidManifest.xml | 4 + .../connectors/PostsConnectorUnitTest.kt | 62 + .../demo/DemoConnectorCompanionUnitTest.kt | 370 +++++ firebase-dataconnect/emulator/.firebaserc | 10 + firebase-dataconnect/emulator/.gitignore | 7 + firebase-dataconnect/emulator/README.md | 258 +++ .../emulator/dataconnect/.gitignore | 1 + .../connector/alltypes/alltypes_ops.gql | 192 +++ .../connector/alltypes/connector.yaml | 2 + .../dataconnect/connector/demo/connector.yaml | 6 + .../dataconnect/connector/demo/demo_ops.gql | 1415 +++++++++++++++++ .../connector/keywords/connector.yaml | 8 + .../connector/keywords/keyword_ops.gql | 55 + .../connector/person/connector.yaml | 2 + .../connector/person/person_ops.gql | 90 ++ .../connector/posts/connector.yaml | 2 + .../dataconnect/connector/posts/posts_ops.gql | 53 + .../emulator/dataconnect/dataconnect.yaml | 17 + .../dataconnect/schema/alltypes_schema.gql | 64 + .../dataconnect/schema/demo_schema.gql | 339 ++++ .../dataconnect/schema/person_schema.gql | 19 + .../dataconnect/schema/posts_schema.gql | 24 + firebase-dataconnect/emulator/emulator.sh | 31 + firebase-dataconnect/emulator/firebase.json | 17 + firebase-dataconnect/emulator/servers.json | 22 + .../emulator/start_postgres_pod.sh | 82 + .../emulator/wipe_postgres_db.sh | 28 + .../firebase-dataconnect.gradle.kts | 183 +++ firebase-dataconnect/google-services.json | 24 + firebase-dataconnect/gradle.properties | 2 + .../gradleplugin/gradle.properties | 2 + .../gradleplugin/gradle/libs.versions.toml | 10 + .../gradle/wrapper/gradle-wrapper.properties | 7 + .../gradleplugin/plugin/build.gradle.kts | 45 + .../gradle/plugin/TransformerInterop.java | 35 + .../gradle/plugin/AgpKotlinExtensions.kt | 36 + .../gradle/plugin/DataConnectDslExtension.kt | 241 +++ .../gradle/plugin/DataConnectExecutable.kt | 95 ++ .../DataConnectExecutableDownloadTask.kt | 202 +++ .../plugin/DataConnectExecutableLauncher.kt | 91 ++ .../plugin/DataConnectGenerateCodeTask.kt | 79 + .../plugin/DataConnectGradleException.kt | 21 + .../gradle/plugin/DataConnectGradlePlugin.kt | 203 +++ .../gradle/plugin/DataConnectLocalSettings.kt | 158 ++ .../DataConnectMergeConfigDirectoriesTask.kt | 119 ++ .../gradle/plugin/DataConnectProviders.kt | 134 ++ .../plugin/DataConnectRunEmulatorTask.kt | 66 + .../plugin/DataConnectVariantDslExtension.kt | 192 +++ .../dataconnect/gradle/plugin/Util.kt | 48 + .../gradleplugin/settings.gradle.kts | 35 + firebase-dataconnect/lint.xml | 8 + .../scripts/compile_kotlin.sh | 50 + firebase-dataconnect/scripts/run_all_tests.sh | 46 + .../scripts/run_integration_tests.sh | 42 + .../scripts/run_unit_tests.sh | 42 + firebase-dataconnect/scripts/spotlessApply.sh | 42 + .../src/androidTest/AndroidManifest.xml | 14 + .../dataconnect/AnyScalarIntegrationTest.kt | 1089 +++++++++++++ .../dataconnect/AppCheckIntegrationTest.kt | 234 +++ .../dataconnect/AuthIntegrationTest.kt | 224 +++ .../DataConnectUntypedDataIntegrationTest.kt | 402 +++++ ...aConnectUntypedVariablesIntegrationTest.kt | 108 ++ .../FirebaseDataConnectIntegrationTest.kt | 399 +++++ .../GrpcMetadataIntegrationTest.kt | 410 +++++ .../dataconnect/QueryRefIntegrationTest.kt | 376 +++++ .../QuerySubscriptionIntegrationTest.kt | 601 +++++++ .../testutil/FirebaseAppIdTestUtil.kt | 23 + .../InProcessDataConnectGrpcServer.kt | 179 +++ .../testutil/MutationRefImplTestExtensions.kt | 30 + .../testutil/QueryRefImplTestExtensions.kt | 30 + .../testutil/schemas/AllTypesSchema.kt | 238 +++ .../testutil/schemas/PersonSchema.kt | 259 +++ .../schemas/PersonSchemaIntegrationTest.kt | 133 ++ .../src/main/AndroidManifest.xml | 17 + .../google/firebase/dataconnect/AnyValue.kt | 267 ++++ .../firebase/dataconnect/ConnectorConfig.kt | 87 + .../firebase/dataconnect/DataConnectError.kt | 89 ++ .../dataconnect/DataConnectException.kt | 21 + .../dataconnect/DataConnectSettings.kt | 82 + .../dataconnect/DataConnectUntypedData.kt | 47 + .../DataConnectUntypedVariables.kt | 47 + .../dataconnect/FirebaseDataConnect.kt | 426 +++++ .../google/firebase/dataconnect/LogLevel.kt | 34 + .../firebase/dataconnect/MutationRef.kt | 51 + .../firebase/dataconnect/OperationRef.kt | 270 ++++ .../firebase/dataconnect/OptionalVariable.kt | 178 +++ .../google/firebase/dataconnect/QueryRef.kt | 62 + .../firebase/dataconnect/QuerySubscription.kt | 146 ++ .../dataconnect/core/DataConnectAppCheck.kt | 62 + .../dataconnect/core/DataConnectAuth.kt | 58 + .../DataConnectCredentialsTokenManager.kt | 511 ++++++ .../dataconnect/core/DataConnectGrpcClient.kt | 187 +++ .../core/DataConnectGrpcMetadata.kt | 164 ++ .../dataconnect/core/DataConnectGrpcRPCs.kt | 349 ++++ .../core/FirebaseDataConnectFactory.kt | 155 ++ .../core/FirebaseDataConnectImpl.kt | 444 ++++++ .../core/FirebaseDataConnectRegistrar.kt | 81 + .../firebase/dataconnect/core/Globals.kt | 132 ++ .../firebase/dataconnect/core/Logger.kt | 88 + .../dataconnect/core/MutationRefImpl.kt | 114 ++ .../dataconnect/core/OperationRefImpl.kt | 84 + .../firebase/dataconnect/core/QueryRefImpl.kt | 79 + .../dataconnect/core/QuerySubscriptionImpl.kt | 119 ++ .../core/QuerySubscriptionInternal.kt | 27 + .../generated/GeneratedConnector.kt | 74 + .../generated/GeneratedMutation.kt | 49 + .../generated/GeneratedOperation.kt | 107 ++ .../dataconnect/generated/GeneratedQuery.kt | 49 + .../dataconnect/querymgr/LiveQueries.kt | 121 ++ .../dataconnect/querymgr/LiveQuery.kt | 261 +++ .../dataconnect/querymgr/QueryManager.kt | 48 + .../querymgr/RegisteredDataDeserialzer.kt | 170 ++ .../serializers/AnyValueSerializer.kt | 45 + .../dataconnect/serializers/DateSerializer.kt | 76 + .../serializers/TimestampSerializer.kt | 129 ++ .../dataconnect/serializers/UUIDSerializer.kt | 76 + .../util/AlphanumericStringUtil.kt | 73 + .../dataconnect/util/NullOutputStream.kt | 24 + .../dataconnect/util/NullableReference.kt | 22 + .../dataconnect/util/ProtoStructDecoder.kt | 591 +++++++ .../dataconnect/util/ProtoStructEncoder.kt | 445 ++++++ .../firebase/dataconnect/util/ProtoUtil.kt | 517 ++++++ .../dataconnect/util/ReferenceCounted.kt | 108 ++ .../dataconnect/util/SequencedReference.kt | 79 + .../dataconnect/util/SuspendingLazy.kt | 67 + .../dataconnect/proto/connector_service.proto | 104 ++ .../dataconnect/proto/emulator_service.proto | 79 + .../dataconnect/proto/graphql_error.proto | 85 + .../src/test/AndroidManifest.xml | 13 + .../dataconnect/AnyValueSerializerUnitTest.kt | 48 + .../firebase/dataconnect/AnyValueUnitTest.kt | 603 +++++++ .../dataconnect/ConnectorConfigUnitTest.kt | 236 +++ .../dataconnect/DataConnectErrorUnitTest.kt | 296 ++++ .../DataConnectSettingsUnitTest.kt | 186 +++ .../dataconnect/PathSegmentFieldUnitTest.kt | 74 + .../PathSegmentListIndexUnitTest.kt | 74 + .../dataconnect/ProtoStructDecoderUnitTest.kt | 738 +++++++++ .../dataconnect/ProtoStructEncoderUnitTest.kt | 398 +++++ .../dataconnect/SerializationTestData.kt | 288 ++++ .../firebase/dataconnect/UtilUnitTest.kt | 178 +++ .../core/DataConnectAuthUnitTest.kt | 619 +++++++ .../core/DataConnectGrpcClientUnitTest.kt | 686 ++++++++ .../core/DataConnectGrpcMetadataUnitTest.kt | 308 ++++ .../core/FirebaseDataConnectImplUnitTest.kt | 202 +++ .../core/MutationRefImplUnitTest.kt | 523 ++++++ .../core/MutationResultUnitTest.kt | 219 +++ .../core/OperationRefImplUnitTest.kt | 377 +++++ .../dataconnect/core/QueryRefImplUnitTest.kt | 436 +++++ .../dataconnect/core/QueryResultUnitTest.kt | 219 +++ .../TimestampSerializerUnitTest.kt | 292 ++++ .../firebase/dataconnect/testutil/Arbs.kt | 129 ++ .../testutil/DataConnectAnySerializer.kt | 79 + .../dataconnect/testutil/MockLogger.kt | 96 ++ .../firebase/dataconnect/testutil/Stubs.kt | 69 + firebase-dataconnect/testutil/README.md | 4 + .../google/firebase/FirebaseAppTestUtils.kt | 31 + .../testutil/AccessTokenTestUtils.kt | 33 + .../testutil/AnyScalarTestUtils.kt | 32 + .../firebase/dataconnect/testutil/Arbs.kt | 165 ++ .../testutil/DataConnectLogLevelRule.kt | 40 + .../dataconnect/testutil/DateTimeTestUtils.kt | 126 ++ .../dataconnect/testutil/DelayedDeferred.kt | 66 + .../dataconnect/testutil/EdgeCases.kt | 85 + .../dataconnect/testutil/EmptyVariables.kt | 45 + .../dataconnect/testutil/FactoryTestRule.kt | 57 + .../testutil/FirebaseAppUnitTestingRule.kt | 99 ++ .../dataconnect/testutil/ImmediateDeferred.kt | 30 + .../dataconnect/testutil/KotestUtils.kt | 35 + .../testutil/MutationRefTestExtensions.kt | 47 + .../testutil/QueryRefTestExtensions.kt | 130 ++ .../testutil/RandomSeedTestRule.kt | 47 + .../testutil/SuspendingCountDownLatch.kt | 73 + .../dataconnect/testutil/TestUtils.kt | 233 +++ .../testutil/UnavailableDeferred.kt | 24 + .../resources/io/mockk/settings.properties | 1 + .../testutil/testutil.gradle.kts | 71 + gradle/libs.versions.toml | 34 +- settings.gradle | 4 + subprojects.cfg | 4 + 223 files changed, 35244 insertions(+), 6 deletions(-) create mode 100644 firebase-dataconnect/.gitignore create mode 100644 firebase-dataconnect/CHANGELOG.md create mode 100644 firebase-dataconnect/README.md create mode 100644 firebase-dataconnect/androidTestutil/androidTestutil.gradle.kts create mode 100644 firebase-dataconnect/androidTestutil/src/main/AndroidManifest.xml create mode 100644 firebase-dataconnect/androidTestutil/src/main/assets/firebase-admin-service-account.key.json create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackend.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectIntegrationTestBase.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAuthBackend.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/InstrumentationUtils.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestAppCheckProvider.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestDataConnectFactory.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestFirebaseAppFactory.kt create mode 100644 firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TurbineUtils.kt create mode 100644 firebase-dataconnect/androidTestutil/src/test/AndroidManifest.xml create mode 100644 firebase-dataconnect/androidTestutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectBackendUnitTest.kt create mode 100644 firebase-dataconnect/api.txt create mode 100644 firebase-dataconnect/connectors/connectors.gradle.kts create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/AnyScalarIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NestedStructsIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/NoVariablesIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OnAndViaRelationsIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationBasicsIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OperationExecuteIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/OptionalArgumentsIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ScalarVariablesAndDataIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/SyntheticIdIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/TimestampScalarIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorIntegrationTestBase.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/DemoConnectorTruth.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/testutil/TestDemoConnectorFactory.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/KeywordsConnectorIntegrationTest.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/keywords/TestKeywordsConnectorFactory.kt create mode 100644 firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/testutil/TestConnectorFactory.kt create mode 100644 firebase-dataconnect/connectors/src/main/AndroidManifest.xml create mode 100644 firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreateComment.kt create mode 100644 firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/CreatePost.kt create mode 100644 firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/GetPost.kt create mode 100644 firebase-dataconnect/connectors/src/main/kotlin/com/google/firebase/dataconnect/connectors/PostsConnector.kt create mode 100644 firebase-dataconnect/connectors/src/test/AndroidManifest.xml create mode 100644 firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/PostsConnectorUnitTest.kt create mode 100644 firebase-dataconnect/connectors/src/test/kotlin/com/google/firebase/dataconnect/connectors/demo/DemoConnectorCompanionUnitTest.kt create mode 100644 firebase-dataconnect/emulator/.firebaserc create mode 100644 firebase-dataconnect/emulator/.gitignore create mode 100644 firebase-dataconnect/emulator/README.md create mode 100644 firebase-dataconnect/emulator/dataconnect/.gitignore create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/alltypes/connector.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/demo/connector.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/keywords/connector.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/keywords/keyword_ops.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/person/connector.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/posts/connector.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/connector/posts/posts_ops.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/dataconnect.yaml create mode 100644 firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/schema/person_schema.gql create mode 100644 firebase-dataconnect/emulator/dataconnect/schema/posts_schema.gql create mode 100755 firebase-dataconnect/emulator/emulator.sh create mode 100644 firebase-dataconnect/emulator/firebase.json create mode 100644 firebase-dataconnect/emulator/servers.json create mode 100755 firebase-dataconnect/emulator/start_postgres_pod.sh create mode 100755 firebase-dataconnect/emulator/wipe_postgres_db.sh create mode 100644 firebase-dataconnect/firebase-dataconnect.gradle.kts create mode 100644 firebase-dataconnect/google-services.json create mode 100644 firebase-dataconnect/gradle.properties create mode 100644 firebase-dataconnect/gradleplugin/gradle.properties create mode 100644 firebase-dataconnect/gradleplugin/gradle/libs.versions.toml create mode 100644 firebase-dataconnect/gradleplugin/gradle/wrapper/gradle-wrapper.properties create mode 100644 firebase-dataconnect/gradleplugin/plugin/build.gradle.kts create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/java/com/google/firebase/dataconnect/gradle/plugin/TransformerInterop.java create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/AgpKotlinExtensions.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectDslExtension.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutable.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableDownloadTask.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectExecutableLauncher.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGenerateCodeTask.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradleException.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectGradlePlugin.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectLocalSettings.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectMergeConfigDirectoriesTask.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectProviders.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectRunEmulatorTask.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/DataConnectVariantDslExtension.kt create mode 100644 firebase-dataconnect/gradleplugin/plugin/src/main/kotlin/com/google/firebase/dataconnect/gradle/plugin/Util.kt create mode 100644 firebase-dataconnect/gradleplugin/settings.gradle.kts create mode 100644 firebase-dataconnect/lint.xml create mode 100755 firebase-dataconnect/scripts/compile_kotlin.sh create mode 100755 firebase-dataconnect/scripts/run_all_tests.sh create mode 100755 firebase-dataconnect/scripts/run_integration_tests.sh create mode 100755 firebase-dataconnect/scripts/run_unit_tests.sh create mode 100755 firebase-dataconnect/scripts/spotlessApply.sh create mode 100644 firebase-dataconnect/src/androidTest/AndroidManifest.xml create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AppCheckIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AuthIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedDataIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariablesIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/FirebaseDataConnectIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/GrpcMetadataIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QueryRefIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/QuerySubscriptionIntegrationTest.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppIdTestUtil.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/InProcessDataConnectGrpcServer.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/MutationRefImplTestExtensions.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/QueryRefImplTestExtensions.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/AllTypesSchema.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchemaIntegrationTest.kt create mode 100644 firebase-dataconnect/src/main/AndroidManifest.xml create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/AnyValue.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/ConnectorConfig.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectError.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectException.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectSettings.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedData.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectUntypedVariables.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/FirebaseDataConnect.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/LogLevel.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/MutationRef.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OperationRef.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/OptionalVariable.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QueryRef.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/QuerySubscription.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAppCheck.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectAuth.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectCredentialsTokenManager.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClient.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadata.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcRPCs.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectFactory.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImpl.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectRegistrar.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Globals.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/Logger.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/MutationRefImpl.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/OperationRefImpl.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryRefImpl.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionImpl.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QuerySubscriptionInternal.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedConnector.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedMutation.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedOperation.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/generated/GeneratedQuery.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQueries.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/LiveQuery.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/QueryManager.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/querymgr/RegisteredDataDeserialzer.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/AnyValueSerializer.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/DateSerializer.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializer.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/UUIDSerializer.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/AlphanumericStringUtil.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullOutputStream.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/NullableReference.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructDecoder.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoStructEncoder.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ProtoUtil.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/ReferenceCounted.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SequencedReference.kt create mode 100644 firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/util/SuspendingLazy.kt create mode 100644 firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/connector_service.proto create mode 100644 firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/emulator_service.proto create mode 100644 firebase-dataconnect/src/main/proto/google/firebase/dataconnect/proto/graphql_error.proto create mode 100644 firebase-dataconnect/src/test/AndroidManifest.xml create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueSerializerUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/AnyValueUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ConnectorConfigUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectErrorUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/DataConnectSettingsUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentFieldUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/PathSegmentListIndexUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructEncoderUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/SerializationTestData.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/UtilUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectAuthUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcClientUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/DataConnectGrpcMetadataUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/FirebaseDataConnectImplUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationRefImplUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/MutationResultUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/OperationRefImplUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryRefImplUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryResultUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/TimestampSerializerUnitTest.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/DataConnectAnySerializer.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/MockLogger.kt create mode 100644 firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/testutil/Stubs.kt create mode 100644 firebase-dataconnect/testutil/README.md create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/FirebaseAppTestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AccessTokenTestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/AnyScalarTestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/Arbs.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DataConnectLogLevelRule.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DateTimeTestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/DelayedDeferred.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EdgeCases.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/EmptyVariables.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FactoryTestRule.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/FirebaseAppUnitTestingRule.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ImmediateDeferred.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/KotestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/MutationRefTestExtensions.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/QueryRefTestExtensions.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/RandomSeedTestRule.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/SuspendingCountDownLatch.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/TestUtils.kt create mode 100644 firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/UnavailableDeferred.kt create mode 100644 firebase-dataconnect/testutil/src/main/resources/io/mockk/settings.properties create mode 100644 firebase-dataconnect/testutil/testutil.gradle.kts 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 From 92a824b97a5c7ebc0bacdffbdddc3d0eee416f3c Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Wed, 25 Sep 2024 12:40:52 -0400 Subject: [PATCH 10/18] Improvements to vertexAI types (#6309) A collection of improvements to the VertexAI SDK: - Make properties of `GenerativeModel` private - Rename all `blob.*` to `inlineData.*` - Improvements to `FunctionCallingConfig` - Add support for `frequencyPenalty` and `presencePenalty` - Add support for `HarmBlockMethod` - Add support for `title` and `publicationDate` in `Citation - Make error handling more robust when details are missing --------- Co-authored-by: Daymon <17409137+daymxn@users.noreply.github.com> --- .../com/google/firebase/vertexai/Chat.kt | 16 ++--- .../firebase/vertexai/GenerativeModel.kt | 12 ++-- .../firebase/vertexai/common/server/Types.kt | 17 ++++- .../firebase/vertexai/common/shared/Types.kt | 7 ++- .../vertexai/internal/util/conversions.kt | 63 ++++++++++++++----- .../firebase/vertexai/type/Candidate.kt | 8 ++- .../google/firebase/vertexai/type/Content.kt | 6 +- .../vertexai/type/FunctionCallingConfig.kt | 30 ++++++++- .../vertexai/type/GenerationConfig.kt | 14 +++++ .../firebase/vertexai/type/HarmBlockMethod.kt | 31 +++++++++ .../com/google/firebase/vertexai/type/Part.kt | 8 +-- .../firebase/vertexai/type/SafetySetting.kt | 7 ++- .../com/google/firebase/vertexai/type/Tool.kt | 17 ++++- .../firebase/vertexai/type/ToolConfig.kt | 15 +---- 14 files changed, 192 insertions(+), 59 deletions(-) create mode 100644 firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt index 9b7a378a2bf..e44adea961a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt @@ -17,10 +17,10 @@ package com.google.firebase.vertexai import android.graphics.Bitmap -import com.google.firebase.vertexai.type.BlobPart import com.google.firebase.vertexai.type.Content import com.google.firebase.vertexai.type.GenerateContentResponse import com.google.firebase.vertexai.type.ImagePart +import com.google.firebase.vertexai.type.InlineDataPart import com.google.firebase.vertexai.type.InvalidStateException import com.google.firebase.vertexai.type.TextPart import com.google.firebase.vertexai.type.content @@ -102,13 +102,13 @@ class Chat(private val model: GenerativeModel, val history: MutableList val flow = model.generateContentStream(*history.toTypedArray(), prompt) val bitmaps = LinkedList() - val blobs = LinkedList() + val inlineDataParts = LinkedList() val text = StringBuilder() /** - * TODO: revisit when images and blobs are returned. This will cause issues with how things are - * structured in the response. eg; a text/image/text response will be (incorrectly) represented - * as image/text + * TODO: revisit when images and inline data are returned. This will cause issues with how + * things are structured in the response. eg; a text/image/text response will be (incorrectly) + * represented as image/text */ return flow .onEach { @@ -116,7 +116,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList when (part) { is TextPart -> text.append(part.text) is ImagePart -> bitmaps.add(part.image) - is BlobPart -> blobs.add(part) + is InlineDataPart -> inlineDataParts.add(part) } } } @@ -128,8 +128,8 @@ class Chat(private val model: GenerativeModel, val history: MutableList for (bitmap in bitmaps) { image(bitmap) } - for (blob in blobs) { - blob(blob.mimeType, blob.blob) + for (inlineDataPart in inlineDataParts) { + inlineData(inlineDataPart.mimeType, inlineDataPart.inlineData) } if (text.isNotBlank()) { text(text.toString()) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt index 311d333e2f2..50769073fa5 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt @@ -52,12 +52,12 @@ import kotlinx.coroutines.tasks.await */ class GenerativeModel internal constructor( - val modelName: String, - val generationConfig: GenerationConfig? = null, - val safetySettings: List? = null, - val tools: List? = null, - val toolConfig: ToolConfig? = null, - val systemInstruction: Content? = null, + private val modelName: String, + private val generationConfig: GenerationConfig? = null, + private val safetySettings: List? = null, + private val tools: List? = null, + private val toolConfig: ToolConfig? = null, + private val systemInstruction: Content? = null, private val controller: APIController ) { diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt index cf5f98593e8..742729ffa07 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt @@ -68,10 +68,25 @@ internal constructor(@JsonNames("citations") val citationSources: List + val details: List? = null ) @Serializable internal data class GRpcErrorDetails(val reason: String? = null) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt index ffc077b5d50..b70603c5de0 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt @@ -51,7 +51,8 @@ internal data class Content(@EncodeDefault val role: String? = "user", val parts @Serializable internal data class TextPart(val text: String) : Part -@Serializable internal data class BlobPart(@SerialName("inline_data") val inlineData: Blob) : Part +@Serializable +internal data class InlineDataPart(@SerialName("inline_data") val inlineData: InlineData) : Part @Serializable internal data class FunctionCallPart(val functionCall: FunctionCall) : Part @@ -73,7 +74,7 @@ internal data class FileData( ) @Serializable -internal data class Blob(@SerialName("mime_type") val mimeType: String, val data: Base64) +internal data class InlineData(@SerialName("mime_type") val mimeType: String, val data: Base64) @Serializable internal data class SafetySetting( @@ -105,7 +106,7 @@ internal object PartSerializer : JsonContentPolymorphicSerializer(Part::cl "text" in jsonObject -> TextPart.serializer() "functionCall" in jsonObject -> FunctionCallPart.serializer() "functionResponse" in jsonObject -> FunctionResponsePart.serializer() - "inlineData" in jsonObject -> BlobPart.serializer() + "inlineData" in jsonObject -> InlineDataPart.serializer() "fileData" in jsonObject -> FileDataPart.serializer() else -> throw SerializationException("Unknown Part type") } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt index 4c30459a6c4..59188094b2c 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt @@ -20,13 +20,12 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 import com.google.firebase.vertexai.common.client.Schema -import com.google.firebase.vertexai.common.shared.Blob import com.google.firebase.vertexai.common.shared.FileData import com.google.firebase.vertexai.common.shared.FunctionCall import com.google.firebase.vertexai.common.shared.FunctionCallPart import com.google.firebase.vertexai.common.shared.FunctionResponse import com.google.firebase.vertexai.common.shared.FunctionResponsePart -import com.google.firebase.vertexai.type.BlobPart +import com.google.firebase.vertexai.common.shared.InlineData import com.google.firebase.vertexai.type.BlockReason import com.google.firebase.vertexai.type.Candidate import com.google.firebase.vertexai.type.Citation @@ -39,11 +38,13 @@ import com.google.firebase.vertexai.type.FunctionCallingConfig import com.google.firebase.vertexai.type.FunctionDeclaration import com.google.firebase.vertexai.type.GenerateContentResponse import com.google.firebase.vertexai.type.GenerationConfig +import com.google.firebase.vertexai.type.HarmBlockMethod import com.google.firebase.vertexai.type.HarmBlockThreshold import com.google.firebase.vertexai.type.HarmCategory import com.google.firebase.vertexai.type.HarmProbability import com.google.firebase.vertexai.type.HarmSeverity import com.google.firebase.vertexai.type.ImagePart +import com.google.firebase.vertexai.type.InlineDataPart import com.google.firebase.vertexai.type.Part import com.google.firebase.vertexai.type.PromptFeedback import com.google.firebase.vertexai.type.SafetyRating @@ -55,6 +56,7 @@ import com.google.firebase.vertexai.type.ToolConfig import com.google.firebase.vertexai.type.UsageMetadata import com.google.firebase.vertexai.type.content import java.io.ByteArrayOutputStream +import java.util.Calendar import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import org.json.JSONObject @@ -71,12 +73,12 @@ internal fun Part.toInternal(): com.google.firebase.vertexai.common.shared.Part return when (this) { is TextPart -> com.google.firebase.vertexai.common.shared.TextPart(text) is ImagePart -> - com.google.firebase.vertexai.common.shared.BlobPart( - Blob("image/jpeg", encodeBitmapToBase64Png(image)) + com.google.firebase.vertexai.common.shared.InlineDataPart( + InlineData("image/jpeg", encodeBitmapToBase64Png(image)) ) - is BlobPart -> - com.google.firebase.vertexai.common.shared.BlobPart( - Blob(mimeType, Base64.encodeToString(blob, BASE_64_FLAGS)) + is InlineDataPart -> + com.google.firebase.vertexai.common.shared.InlineDataPart( + InlineData(mimeType, Base64.encodeToString(inlineData, BASE_64_FLAGS)) ) is com.google.firebase.vertexai.type.FunctionCallPart -> FunctionCallPart(FunctionCall(name, args.orEmpty())) @@ -96,7 +98,8 @@ internal fun Part.toInternal(): com.google.firebase.vertexai.common.shared.Part internal fun SafetySetting.toInternal() = com.google.firebase.vertexai.common.shared.SafetySetting( harmCategory.toInternal(), - threshold.toInternal() + threshold.toInternal(), + method.toInternal() ) internal fun GenerationConfig.toInternal() = @@ -107,11 +110,13 @@ internal fun GenerationConfig.toInternal() = candidateCount = candidateCount, maxOutputTokens = maxOutputTokens, stopSequences = stopSequences, + frequencyPenalty = frequencyPenalty, + presencePenalty = presencePenalty, responseMimeType = responseMimeType, responseSchema = responseSchema?.toInternal() ) -internal fun com.google.firebase.vertexai.type.HarmCategory.toInternal() = +internal fun HarmCategory.toInternal() = when (this) { HarmCategory.HARASSMENT -> com.google.firebase.vertexai.common.shared.HarmCategory.HARASSMENT HarmCategory.HATE_SPEECH -> com.google.firebase.vertexai.common.shared.HarmCategory.HATE_SPEECH @@ -122,6 +127,13 @@ internal fun com.google.firebase.vertexai.type.HarmCategory.toInternal() = HarmCategory.UNKNOWN -> com.google.firebase.vertexai.common.shared.HarmCategory.UNKNOWN } +internal fun HarmBlockMethod.toInternal() = + when (this) { + HarmBlockMethod.SEVERITY -> com.google.firebase.vertexai.common.shared.HarmBlockMethod.SEVERITY + HarmBlockMethod.PROBABILITY -> + com.google.firebase.vertexai.common.shared.HarmBlockMethod.PROBABILITY + } + internal fun ToolConfig.toInternal() = com.google.firebase.vertexai.common.client.ToolConfig( com.google.firebase.vertexai.common.client.FunctionCallingConfig( @@ -150,7 +162,9 @@ internal fun HarmBlockThreshold.toInternal() = } internal fun Tool.toInternal() = - com.google.firebase.vertexai.common.client.Tool(functionDeclarations.map { it.toInternal() }) + com.google.firebase.vertexai.common.client.Tool( + functionDeclarations?.map { it.toInternal() } ?: emptyList() + ) internal fun FunctionDeclaration.toInternal() = com.google.firebase.vertexai.common.client.FunctionDeclaration(name, "", schema.toInternal()) @@ -191,12 +205,12 @@ internal fun com.google.firebase.vertexai.common.shared.Content.toPublic(): Cont internal fun com.google.firebase.vertexai.common.shared.Part.toPublic(): Part { return when (this) { is com.google.firebase.vertexai.common.shared.TextPart -> TextPart(text) - is com.google.firebase.vertexai.common.shared.BlobPart -> { + is com.google.firebase.vertexai.common.shared.InlineDataPart -> { val data = Base64.decode(inlineData.data, BASE_64_FLAGS) if (inlineData.mimeType.contains("image")) { ImagePart(decodeBitmapFromImage(data)) } else { - BlobPart(inlineData.mimeType, data) + InlineDataPart(inlineData.mimeType, data) } } is FunctionCallPart -> @@ -218,8 +232,29 @@ internal fun com.google.firebase.vertexai.common.shared.Part.toPublic(): Part { } } -internal fun com.google.firebase.vertexai.common.server.CitationSources.toPublic() = - Citation(startIndex = startIndex, endIndex = endIndex, uri = uri, license = license) +internal fun com.google.firebase.vertexai.common.server.CitationSources.toPublic(): Citation { + val publicationDateAsCalendar = + publicationDate?.let { + val calendar = Calendar.getInstance() + // Internal `Date.year` uses 0 to represent not specified. We use 1 as default. + val year = if (it.year == null || it.year < 1) 1 else it.year + // Internal `Date.month` uses 0 to represent not specified, or is 1-12 as months. The month as + // expected by [Calendar] is 0-based, so we subtract 1 or use 0 as default. + val month = if (it.month == null || it.month < 1) 0 else it.month - 1 + // Internal `Date.day` uses 0 to represent not specified. We use 1 as default. + val day = if (it.day == null || it.day < 1) 1 else it.day + calendar.set(year, month, day) + calendar + } + return Citation( + title = title, + startIndex = startIndex, + endIndex = endIndex, + uri = uri, + license = license, + publicationDate = publicationDateAsCalendar + ) +} internal fun com.google.firebase.vertexai.common.server.CitationMetadata.toPublic() = CitationMetadata(citationSources.map { it.toPublic() }) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt index 68eba03f4c0..5538c24e139 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt @@ -16,6 +16,8 @@ package com.google.firebase.vertexai.type +import java.util.Calendar + /** A response generated by the model. */ class Candidate internal constructor( @@ -48,19 +50,23 @@ class CitationMetadata internal constructor(val citations: List) * Provides citation information for sourcing of content provided by the model between a given * [startIndex] and [endIndex]. * + * @property title Title of the attribution. * @property startIndex The inclusive beginning of a sequence in a model response that derives from * a cited source. * @property endIndex The exclusive end of a sequence in a model response that derives from a cited * source. * @property uri A link to the cited source, if available. * @property license The license the cited source work is distributed under, if specified. + * @property publicationDate Publication date of the attribution, if available. */ class Citation internal constructor( + val title: String? = null, val startIndex: Int = 0, val endIndex: Int, val uri: String? = null, - val license: String? = null + val license: String? = null, + val publicationDate: Calendar? = null ) /** The reason for content finishing. */ diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt index 80b211465c5..89f07cbb20a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt @@ -48,9 +48,11 @@ class Content @JvmOverloads constructor(val role: String? = "user", val parts: L @JvmName("addText") fun text(text: String) = part(TextPart(text)) /** - * Wraps the provided [blob] and [mimeType] inside a [BlobPart] and adds it to the [parts] list. + * Wraps the provided [bytes] and [mimeType] inside a [InlineDataPart] and adds it to the + * [parts] list. */ - @JvmName("addBlob") fun blob(mimeType: String, blob: ByteArray) = part(BlobPart(mimeType, blob)) + @JvmName("addInlineData") + fun inlineData(mimeType: String, bytes: ByteArray) = part(InlineDataPart(mimeType, bytes)) /** Wraps the provided [image] inside an [ImagePart] and adds it to the [parts] list. */ @JvmName("addImage") fun image(image: Bitmap) = part(ImagePart(image)) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt index 8517164dce2..ba09940c49c 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt @@ -16,6 +16,8 @@ package com.google.firebase.vertexai.type +import com.google.firebase.vertexai.common.client.FunctionCallingConfig + /** * Contains configuration for function calling from the model. This can be used to force function * calling predictions or disable them. @@ -25,10 +27,14 @@ package com.google.firebase.vertexai.type * should match [FunctionDeclaration.name]. With [Mode.ANY], model will predict a function call from * the set of function names provided. */ -class FunctionCallingConfig(val mode: Mode, val allowedFunctionNames: List? = null) { +class FunctionCallingConfig +internal constructor( + internal val mode: Mode, + internal val allowedFunctionNames: List? = null +) { /** Configuration for dictating when the model should call the attached function. */ - enum class Mode { + internal enum class Mode { /** * The default behavior for function calling. The model calls functions to answer queries at its * discretion @@ -44,4 +50,24 @@ class FunctionCallingConfig(val mode: Mode, val allowedFunctionNames: List? = null) = + FunctionCallingConfig(Mode.ANY, allowedFunctionNames) + + /** + * The model will never predict a function call to answer a query. This can also be achieved by + * not passing any tools to the model. + */ + @JvmStatic fun none() = FunctionCallingConfig(Mode.NONE) + } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt index 18a8beb2f8d..16d7554ece5 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt @@ -44,6 +44,10 @@ package com.google.firebase.vertexai.type * unique candidates. Setting `temperature` to 0 will always produce exactly one candidate * regardless of the `candidateCount`. * + * @property presencePenalty Positive penalties. + * + * @property frequencyPenalty Frequency penalties. + * * @property maxOutputTokens Specifies the maximum number of tokens that can be generated in the * response. The number of tokens per word varies depending on the language outputted. Defaults to 0 * (unbounded). @@ -76,6 +80,8 @@ private constructor( val topP: Float?, val candidateCount: Int?, val maxOutputTokens: Int?, + val presencePenalty: Float?, + val frequencyPenalty: Float?, val stopSequences: List?, val responseMimeType: String?, val responseSchema: Schema?, @@ -93,6 +99,10 @@ private constructor( * * @property topP See [GenerationConfig.topP]. * + * @property presencePenalty See [GenerationConfig.presencePenalty] + * + * @property frequencyPenalty See [GenerationConfig.frequencyPenalty] + * * @property candidateCount See [GenerationConfig.candidateCount]. * * @property maxOutputTokens See [GenerationConfig.maxOutputTokens]. @@ -110,6 +120,8 @@ private constructor( @JvmField var topP: Float? = null @JvmField var candidateCount: Int? = null @JvmField var maxOutputTokens: Int? = null + @JvmField var presencePenalty: Float? = null + @JvmField var frequencyPenalty: Float? = null @JvmField var stopSequences: List? = null @JvmField var responseMimeType: String? = null @JvmField var responseSchema: Schema? = null @@ -123,6 +135,8 @@ private constructor( candidateCount = candidateCount, maxOutputTokens = maxOutputTokens, stopSequences = stopSequences, + presencePenalty = presencePenalty, + frequencyPenalty = frequencyPenalty, responseMimeType = responseMimeType, responseSchema = responseSchema, ) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt new file mode 100644 index 00000000000..b4a1fcd17ff --- /dev/null +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.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.vertexai.type + +/** + * Specifies how the block method computes the score that will be compared against the + * [HarmBlockThreshold] in [SafetySetting]. + */ +enum class HarmBlockMethod { + /** + * The harm block method uses both probability and severity scores. See [HarmSeverity] and + * [HarmProbability]. + */ + SEVERITY, + /** The harm block method uses the probability score. See [HarmProbability]. */ + PROBABILITY, +} diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt index 60b31b98a7a..fff7eae7959 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt @@ -39,9 +39,9 @@ class ImagePart(val image: Bitmap) : Part * @param mimeType an IANA standard MIME type. For supported values, see the * [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/send-multimodal-prompts#media_requirements) * . - * @param blob the binary data as a [ByteArray] + * @param inlineData the binary data as a [ByteArray] */ -class BlobPart(val mimeType: String, val blob: ByteArray) : Part +class InlineDataPart(val mimeType: String, val inlineData: ByteArray) : Part /** * Represents function call name and params received from requests. @@ -75,8 +75,8 @@ fun Part.asTextOrNull(): String? = (this as? TextPart)?.text /** Returns the part as a [Bitmap] if it represents an image, and null otherwise */ fun Part.asImageOrNull(): Bitmap? = (this as? ImagePart)?.image -/** Returns the part as a [BlobPart] if it represents a blob, and null otherwise */ -fun Part.asBlobPartOrNull(): BlobPart? = this as? BlobPart +/** Returns the part as a [InlineDataPart] if it represents inline data, and null otherwise */ +fun Part.asInlineDataPartOrNull(): InlineDataPart? = this as? InlineDataPart /** Returns the part as a [FileDataPart] if it represents a file, and null otherwise */ fun Part.asFileDataOrNull(): FileDataPart? = this as? FileDataPart diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt index afc881cd033..1e0bc763097 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt @@ -22,5 +22,10 @@ package com.google.firebase.vertexai.type * * @param harmCategory The relevant [HarmCategory]. * @param threshold The threshold form harm allowable. + * @param method Specify if the threshold is used for probability or severity score. */ -class SafetySetting(val harmCategory: HarmCategory, val threshold: HarmBlockThreshold) {} +class SafetySetting( + val harmCategory: HarmCategory, + val threshold: HarmBlockThreshold, + val method: HarmBlockMethod = HarmBlockMethod.PROBABILITY +) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt index f3708a45d47..0400387c081 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt @@ -22,6 +22,17 @@ package com.google.firebase.vertexai.type * * @param functionDeclarations The set of functions that this tool allows the model access to */ -class Tool( - val functionDeclarations: List, -) +class Tool internal constructor(internal val functionDeclarations: List?) { + companion object { + + /** + * Creates a [Tool] instance that provides the model with access to the [functionDeclarations]. + * + * @param functionDeclarations The list of functions that this tool allows the model access to. + */ + @JvmStatic + fun functionDeclarations(functionDeclarations: List): Tool { + return Tool(functionDeclarations) + } + } +} diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt index 609d930e3e6..38ea3837801 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt @@ -22,17 +22,4 @@ package com.google.firebase.vertexai.type * * @param functionCallingConfig The config for function calling */ -class ToolConfig(val functionCallingConfig: FunctionCallingConfig) { - - companion object { - /** Shorthand to construct a ToolConfig that restricts the model from calling any functions */ - fun never(): ToolConfig = ToolConfig(FunctionCallingConfig(FunctionCallingConfig.Mode.NONE)) - /** - * Shorthand to construct a ToolConfig that restricts the model to always call some function. - * You can optionally [allowedFunctionNames] to restrict the model to only call these functions. - * See [FunctionCallingConfig] for more information. - */ - fun always(allowedFunctionNames: List? = null): ToolConfig = - ToolConfig(FunctionCallingConfig(FunctionCallingConfig.Mode.ANY, allowedFunctionNames)) - } -} +class ToolConfig(val functionCallingConfig: FunctionCallingConfig) From cb132ab4263cd01aa6f506af5cc75394b3d0d9b5 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Wed, 25 Sep 2024 16:45:41 +0000 Subject: [PATCH 11/18] Add OrderDirection support (#6307) --- firebase-dataconnect/CHANGELOG.md | 2 + .../connector/alltypes/alltypes_ops.gql | 30 ++++ .../dataconnect/schema/alltypes_schema.gql | 5 + .../OrderDirectionIntegrationTest.kt | 150 ++++++++++++++++++ .../dataconnect/util/ProtoStructDecoder.kt | 9 +- .../dataconnect/util/ProtoStructEncoder.kt | 2 +- .../firebase/dataconnect/testutil/Arbs.kt | 5 + .../dataconnect/testutil/TestUtils.kt | 11 ++ 8 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OrderDirectionIntegrationTest.kt diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index 1a8bb471488..6080722265a 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -6,6 +6,8 @@ ([#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 `OrderDirection` enum support. + ([#6307](https://github.com/firebase/firebase-android-sdk/pull/6307)) * [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. diff --git a/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql index 575c2d64dfa..9ed5737e8c9 100644 --- a/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql +++ b/firebase-dataconnect/emulator/dataconnect/connector/alltypes/alltypes_ops.gql @@ -190,3 +190,33 @@ query getFarm($id: String!) @auth(level: PUBLIC) { } } } + +############################################################################### +# Operations for table: OrderDirectionTest +############################################################################### + +mutation OrderDirectionTestInsert5( + $tag: String!, + $value1: Int!, + $value2: Int!, + $value3: Int!, + $value4: Int!, + $value5: Int!, +) @auth(level: PUBLIC) { + key1: orderDirectionTest_insert(data: { tag: $tag, value: $value1 }) + key2: orderDirectionTest_insert(data: { tag: $tag, value: $value2 }) + key3: orderDirectionTest_insert(data: { tag: $tag, value: $value3 }) + key4: orderDirectionTest_insert(data: { tag: $tag, value: $value4 }) + key5: orderDirectionTest_insert(data: { tag: $tag, value: $value5 }) +} + +query OrderDirectionTestGetAllByTag( + $tag: String!, + $orderDirection: OrderDirection, +) @auth(level: PUBLIC) { + items: orderDirectionTests( + limit: 10, + orderBy: { value: $orderDirection }, + where: { tag: { eq: $tag } }, + ) { id } +} diff --git a/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql index 47465c76f3a..279340d42d1 100644 --- a/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql +++ b/firebase-dataconnect/emulator/dataconnect/schema/alltypes_schema.gql @@ -62,3 +62,8 @@ type Farmer @table { name: String! parent: Farmer } + +type OrderDirectionTest @table @index(fields: ["tag"]) { + value: Int + tag: String +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OrderDirectionIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OrderDirectionIntegrationTest.kt new file mode 100644 index 00000000000..6dea7fd97af --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/OrderDirectionIntegrationTest.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 + +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.sortedParallelTo +import com.google.firebase.dataconnect.testutil.tag +import io.kotest.common.DelicateKotest +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.property.Arb +import io.kotest.property.arbitrary.distinct +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.next +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Test + +class OrderDirectionIntegrationTest : DataConnectIntegrationTestBase() { + + private val dataConnect: FirebaseDataConnect by lazy { + val connectorConfig = testConnectorConfig.copy(connector = "alltypes") + dataConnectFactory.newInstance(connectorConfig) + } + + @OptIn(DelicateKotest::class) private val uniqueInts = Arb.int().distinct() + + @Test + fun orderDirectionQueryVariableOmittedShouldUseAscendingOrder() = runTest { + val tag = Arb.tag().next(rs) + val values = List(5) { uniqueInts.next(rs) } + val insertedIds = insertRow(tag, values) + + val queryIds = getRowIds(tag) + + queryIds shouldContainExactlyInAnyOrder insertedIds + } + + @Test + fun orderDirectionQueryVariableAscendingOrder() = runTest { + val tag = Arb.tag().next(rs) + val values = List(5) { uniqueInts.next(rs) } + val insertedIds = insertRow(tag, values) + + val queryIds = getRowIds(tag, orderDirection = "ASC") + + val insertedIdsSorted = insertedIds.sortedParallelTo(values) + queryIds shouldContainExactly insertedIdsSorted + } + + @Test + fun orderDirectionQueryVariableDescendingOrder() = runTest { + val tag = Arb.tag().next(rs) + val values = List(5) { uniqueInts.next(rs) } + val insertedIds = insertRow(tag, values) + + val queryIds = getRowIds(tag, orderDirection = "DESC") + + val insertedIdsSorted = insertedIds.sortedParallelTo(values).reversed() + queryIds shouldContainExactly insertedIdsSorted + } + + private suspend fun insertRow(tag: String, values: List): List { + require(values.size == 5) { "values.size must be 5, but got ${values.size}" } + return insertRow(tag, values[0], values[1], values[2], values[3], values[4]) + } + + private suspend fun insertRow( + tag: String, + value1: Int, + value2: Int, + value3: Int, + value4: Int, + value5: Int + ): List { + val variables = OrderDirectionTestInsert5Variables(tag, value1, value2, value3, value4, value5) + val mutationRef = + dataConnect.mutation( + operationName = "OrderDirectionTestInsert5", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + val result = mutationRef.execute() + return result.data.run { listOf(key1.id, key2.id, key3.id, key4.id, key5.id) } + } + + private suspend fun getRowIds(tag: String, orderDirection: String? = null): List { + val optionalOrderDirection = + if (orderDirection !== null) OptionalVariable.Value(orderDirection) + else OptionalVariable.Undefined + val variables = OrderDirectionTestGetAllByTagVariables(tag, optionalOrderDirection) + val queryRef = + dataConnect.query( + operationName = "OrderDirectionTestGetAllByTag", + variables = variables, + dataDeserializer = serializer(), + variablesSerializer = serializer(), + ) + val result = queryRef.execute() + return result.data.items.map { it.id } + } + + @Serializable + data class OrderDirectionTestInsert5Variables( + val tag: String, + val value1: Int, + val value2: Int, + val value3: Int, + val value4: Int, + val value5: Int, + ) + + @Serializable data class OrderDirectionTestKey(val id: String) + + @Serializable + data class OrderDirectionTestInsert5Data( + val key1: OrderDirectionTestKey, + val key2: OrderDirectionTestKey, + val key3: OrderDirectionTestKey, + val key4: OrderDirectionTestKey, + val key5: OrderDirectionTestKey, + ) + + @Serializable + data class OrderDirectionTestGetAllByTagVariables( + val tag: String, + val orderDirection: OptionalVariable, + ) + + @Serializable + data class OrderDirectionTestGetAllByTagData(val items: List) { + @Serializable data class Item(val id: String) + } +} 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 index 1dbb0ea0ec5..10af8fc2b53 100644 --- 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 @@ -80,8 +80,8 @@ private object ProtoDecoderUtil { 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 decodeEnum(value: Value, path: String?): String = + decode(value, path, KindCase.STRING_VALUE) { it.stringValue } fun decodeFloat(value: Value, path: String?): Float = decode(value, path, KindCase.NUMBER_VALUE) { it.numberValue.toFloat() } @@ -134,7 +134,10 @@ internal class ProtoValueDecoder( override fun decodeDouble() = decodeDouble(valueProto, path) - override fun decodeEnum(enumDescriptor: SerialDescriptor) = decodeEnum(valueProto, path) + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + val enumValueName = decodeEnum(valueProto, path) + return enumDescriptor.getElementIndex(enumValueName) + } override fun decodeFloat() = decodeFloat(valueProto, 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 index 431f50159c0..5c442c95b9a 100644 --- 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 @@ -68,7 +68,7 @@ internal class ProtoValueEncoder( } override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { - onValue(index.toValueProto()) + onValue(enumDescriptor.getElementName(index).toValueProto()) } override fun encodeFloat(value: Float) { 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 index 836e3d99b2e..98a65552747 100644 --- 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 @@ -22,6 +22,7 @@ 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.alphanumeric import io.kotest.property.arbitrary.arabic import io.kotest.property.arbitrary.arbitrary import io.kotest.property.arbitrary.ascii @@ -163,3 +164,7 @@ fun Arb>.filterNotIncludesAllMatchingAnyScalars(values: List) fun Arb.Companion.callerSdkType(): Arb = arbitrary { if (Arb.boolean().bind()) CallerSdkType.Base else CallerSdkType.Generated } + +fun Arb.Companion.tag(): Arb = arbitrary { + "tag" + Arb.string(size = 10, Codepoint.alphanumeric()).bind() +} 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 index 59825863067..c5e2f5fed07 100644 --- 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 @@ -231,3 +231,14 @@ fun TestScope.newBackgroundScopeThatAdvancesLikeForeground(): CoroutineScope { backgroundContextWithoutBackgroundWork + Job(backgroundContextWithoutBackgroundWork[Job]) ) } + +/** Sorts the given list and makes the same transformation on this list. */ +fun > List.sortedParallelTo(other: List): List { + require(size == other.size) { + "size must equal other.size, but they are unequal: size=$size other.size=${other.size}" + } + val zippedList = other.zip(this) + val sortedZippedList = zippedList.sortedBy { it.first } + val (_, sortedThis) = sortedZippedList.unzip() + return sortedThis +} From 552132b4540fc7ddb590b43f1505ea83e40cff82 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Wed, 25 Sep 2024 16:59:52 +0000 Subject: [PATCH 12/18] ConfigCacheClientTest.java: fix flaky test due to race condition (#6306) --- .../internal/ConfigCacheClientTest.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java index 5badaaf98d4..d3fe5fa22f6 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigCacheClientTest.java @@ -42,6 +42,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -220,7 +221,7 @@ public void getBlocking_hasCachedValue_returnsCache() throws Exception { @Test public void getBlocking_hasNoCachedValueAndFileReadTimesOut_returnsNull() throws Exception { - when(mockStorageClient.read()).thenReturn(configContainer); + when(mockStorageClient.read()).thenAnswer(BLOCK_INDEFINITELY); ConfigContainer container = cacheClient.getBlocking(/* diskReadTimeoutInSeconds= */ 0L); @@ -329,4 +330,18 @@ public void cleanUp() { cacheThreadPool.shutdownNow(); testingThreadPool.shutdownNow(); } + + /** + * A Mockito "answer" that blocks indefinitely. The only way that {@link Answer#answer} will + * return is if its thread is interrupted. This may be useful to cause a method to never return, + * which should result in a timeout waiting for the operation to complete. + *

+ * Example: + * {@code when(foo.get()).thenAnswer(BLOCK_INDEFINITELY); } + */ + private static final Answer BLOCK_INDEFINITELY = + invocation -> { + Thread.sleep(Long.MAX_VALUE); + throw new RuntimeException("BLOCK_INDEFINITELY.answer() should never get here"); + }; } From 885b66ffb01b0ebe7d456e68abbd7e6db4f14adc Mon Sep 17 00:00:00 2001 From: emilypgoogle <110422458+emilypgoogle@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:09:51 -0500 Subject: [PATCH 13/18] Add consumer proguard rules (#6279) --- firebase-vertexai/consumer-rules.pro | 23 ++++++++++++++++++ .../firebase-vertexai.gradle.kts | 10 ++++++++ firebase-vertexai/proguard-rules.pro | 21 ++++++++++++++++ .../firebase/vertexai/common/APIController.kt | 24 ------------------- .../firebase/vertexai/common/util/tests.kt | 9 +++++-- .../google/firebase/vertexai/util/tests.kt | 9 +++++-- 6 files changed, 68 insertions(+), 28 deletions(-) create mode 100644 firebase-vertexai/consumer-rules.pro create mode 100644 firebase-vertexai/proguard-rules.pro diff --git a/firebase-vertexai/consumer-rules.pro b/firebase-vertexai/consumer-rules.pro new file mode 100644 index 00000000000..7947f53cd58 --- /dev/null +++ b/firebase-vertexai/consumer-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class com.google.firebase.vertexai.common.** { *; } diff --git a/firebase-vertexai/firebase-vertexai.gradle.kts b/firebase-vertexai/firebase-vertexai.gradle.kts index 59306df92a9..9bdabd0f0ce 100644 --- a/firebase-vertexai/firebase-vertexai.gradle.kts +++ b/firebase-vertexai/firebase-vertexai.gradle.kts @@ -42,9 +42,19 @@ android { defaultConfig { minSdk = 21 targetSdk = 34 + consumerProguardFiles("consumer-rules.pro") multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 diff --git a/firebase-vertexai/proguard-rules.pro b/firebase-vertexai/proguard-rules.pro new file mode 100644 index 00000000000..f1b424510da --- /dev/null +++ b/firebase-vertexai/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt index f81fa0fc99a..bee23d52613 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt @@ -17,7 +17,6 @@ package com.google.firebase.vertexai.common import android.util.Log -import androidx.annotation.VisibleForTesting import com.google.firebase.vertexai.common.server.FinishReason import com.google.firebase.vertexai.common.util.decodeToFlow import com.google.firebase.vertexai.common.util.fullModelName @@ -25,8 +24,6 @@ import com.google.firebase.vertexai.type.RequestOptions import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -39,12 +36,9 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType -import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json -import io.ktor.utils.io.ByteChannel import kotlin.math.max import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -94,24 +88,6 @@ internal constructor( headerProvider: HeaderProvider? = null, ) : this(key, model, requestOptions, OkHttp.create(), apiClient, headerProvider) - @VisibleForTesting(otherwise = VisibleForTesting.NONE) - constructor( - key: String, - model: String, - requestOptions: RequestOptions, - apiClient: String, - headerProvider: HeaderProvider?, - channel: ByteChannel, - status: HttpStatusCode, - ) : this( - key, - model, - requestOptions, - MockEngine { respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) }, - apiClient, - headerProvider, - ) - private val model = fullModelName(model) private val client = diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt index 8a7184e9851..db42d791c30 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/util/tests.kt @@ -28,7 +28,11 @@ import com.google.firebase.vertexai.common.shared.TextPart import com.google.firebase.vertexai.type.RequestOptions import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import io.ktor.utils.io.ByteChannel import io.ktor.utils.io.close import io.ktor.utils.io.writeFully @@ -106,10 +110,11 @@ internal fun commonTest( "super_cool_test_key", "gemini-pro", requestOptions, + MockEngine { + respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, TEST_CLIENT_ID, null, - channel, - status, ) CommonTestScope(channel, apiController).block() } diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt index 7afb202c1d6..29b7923e35b 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/util/tests.kt @@ -21,7 +21,11 @@ import com.google.firebase.vertexai.common.APIController import com.google.firebase.vertexai.type.RequestOptions import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import io.ktor.utils.io.ByteChannel import io.ktor.utils.io.close import io.ktor.utils.io.writeFully @@ -93,10 +97,11 @@ internal fun commonTest( "super_cool_test_key", "gemini-pro", requestOptions, + MockEngine { + respond(channel, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, TEST_CLIENT_ID, null, - channel, - status, ) val model = GenerativeModel("cool-model-name", controller = apiController) CommonTestScope(channel, model).block() From ff3efc75206af98801121eb1efee90865afd002b Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Fri, 27 Sep 2024 15:12:43 +0000 Subject: [PATCH 14/18] dataconnect: upgrade toolkit version to 1.4.0 (was 1.3.8) (#6312) --- .../gradle/plugin/DataConnectExecutable.kt | 20 +++++++++++++++++++ .../gradle/plugin/DataConnectProviders.kt | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) 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 index b8730c0f8e6..0fcf9cb8b71 100644 --- 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 @@ -66,6 +66,20 @@ sealed interface DataConnectExecutable { "aea3583ebe1a36938eec5164de79405951ddf05b70a857ddb4f346f1424666f1d96" + "989a5f81326c7e2aef4a195d31ff356fdf2331ed98fa1048c4bd469cbfd97" ) + "1.3.9" -> + VerificationInfo( + fileSizeInBytes = 24_977_560L, + sha512DigestHex = + "4558928c2a84b54113e0d6918907eb75bdeb9bd059dcc4b6f22cb4a7c9c7421a357" + + "7f3b0d2eeb246b1df739b38f1eb91e5a6166b0e559707746d79e6ccdf9ed4" + ) + "1.4.0" -> + VerificationInfo( + fileSizeInBytes = 25_018_520L, + sha512DigestHex = + "c06ccade89cb46459452f71c6d49a01b4b30c9f96cc4cb770ed168e7420ef0cb368" + + "cd602ff596137e6586270046cf0ffd9f8d294e44b036e5c5b373a074b7e5a" + ) else -> throw DataConnectGradleException( "3svd27ch8y", @@ -86,10 +100,16 @@ sealed interface DataConnectExecutable { data class Version(val version: String, val verificationInfo: VerificationInfo?) : DataConnectExecutable { companion object { + + private const val DEFAULT_VERSION = "1.4.0" + fun forVersionWithDefaultVerificationInfo(version: String): Version { val verificationInfo = DataConnectExecutable.VerificationInfo.forVersion(version) return Version(version, verificationInfo) } + + fun forDefaultVersionWithDefaultVerificationInfo(): Version = + forVersionWithDefaultVerificationInfo(DEFAULT_VERSION) } } } 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 index 0adf63688bc..698f5b28ae2 100644 --- 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 @@ -50,7 +50,7 @@ class DataConnectProviders( .orElse(versionValueFromGradleProperty) .orElse(valueFromVariant) .orElse(valueFromProject) - .orElse(DataConnectExecutable.Version.forVersionWithDefaultVerificationInfo("1.3.8")) + .orElse(DataConnectExecutable.Version.forDefaultVersionWithDefaultVerificationInfo()) } val postgresConnectionUrl: Provider = run { From 1f6afc700687d31a985622c74441251538a49254 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Fri, 27 Sep 2024 15:07:59 -0400 Subject: [PATCH 15/18] Update endpoint (#6300) Update the endpoint used by vertexAI b/365756293 --- .../firebase/vertexai/common/APIController.kt | 28 +++++++++++++++++-- .../firebase/vertexai/common/server/Types.kt | 7 ++++- .../firebase/vertexai/type/RequestOptions.kt | 4 +-- .../vertexai/common/APIControllerTests.kt | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt index bee23d52613..7f145e10fd6 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/APIController.kt @@ -17,7 +17,11 @@ package com.google.firebase.vertexai.common import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.options import com.google.firebase.vertexai.common.server.FinishReason +import com.google.firebase.vertexai.common.server.GRpcError +import com.google.firebase.vertexai.common.server.GRpcErrorDetails import com.google.firebase.vertexai.common.util.decodeToFlow import com.google.firebase.vertexai.common.util.fullModelName import com.google.firebase.vertexai.type.RequestOptions @@ -239,12 +243,32 @@ private suspend fun validateResponse(response: HttpResponse) { if (message.contains("quota")) { throw QuotaExceededException(message) } - if (error.details?.any { "SERVICE_DISABLED" == it.reason } == true) { - throw ServiceDisabledException(message) + getServiceDisabledErrorDetailsOrNull(error)?.let { + val errorMessage = + if (it.metadata?.get("service") == "firebasevertexai.googleapis.com") { + """ + The Vertex AI for Firebase SDK requires the Firebase Vertex AI API + `firebasevertexai.googleapis.com` to be enabled for your project. Enable it by visiting + the Firebase Console at https://console.firebase.google.com/project/${Firebase.options.projectId}/genai/vertex then + retry. If you enabled this API recently, wait a few minutes for the action to propagate + to our systems and retry. + """ + .trimIndent() + } else { + error.message + } + + throw ServiceDisabledException(errorMessage) } throw ServerException(message) } +private fun getServiceDisabledErrorDetailsOrNull(error: GRpcError): GRpcErrorDetails? { + return error.details?.firstOrNull { + it.reason == "SERVICE_DISABLED" && it.domain == "googleapis.com" + } +} + private fun GenerateContentResponse.validate() = apply { if ((candidates?.isEmpty() != false) && promptFeedback == null) { throw SerializationException("Error deserializing response, found no valid fields") diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt index 742729ffa07..3749d534e47 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/server/Types.kt @@ -163,4 +163,9 @@ internal data class GRpcError( val details: List? = null ) -@Serializable internal data class GRpcErrorDetails(val reason: String? = null) +@Serializable +internal data class GRpcErrorDetails( + val reason: String? = null, + val domain: String? = null, + val metadata: Map? = null +) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt index 0fedcdb5c4f..dbfbc5b9367 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt @@ -25,8 +25,8 @@ import kotlin.time.toDuration class RequestOptions internal constructor( internal val timeout: Duration, - internal val endpoint: String = "https://firebaseml.googleapis.com", - internal val apiVersion: String = "v2beta", + internal val endpoint: String = "https://firebasevertexai.googleapis.com", + internal val apiVersion: String = "v1beta", ) { /** diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt index 582678cf306..8937b13569b 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/common/APIControllerTests.kt @@ -108,7 +108,7 @@ internal class RequestFormatTests { } } - mockEngine.requestHistory.first().url.host shouldBe "firebaseml.googleapis.com" + mockEngine.requestHistory.first().url.host shouldBe "firebasevertexai.googleapis.com" } @Test From ffcc8bab61525f368fa22b341bd2e3c512a8b513 Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Fri, 27 Sep 2024 19:17:50 +0000 Subject: [PATCH 16/18] dataconnect: gradleplugin now gets kotlin version from the main libs.versions.toml (#6320) --- .../gradleplugin/gradle/libs.versions.toml | 2 -- firebase-dataconnect/gradleplugin/plugin/build.gradle.kts | 2 +- firebase-dataconnect/gradleplugin/settings.gradle.kts | 7 +++++++ gradle/libs.versions.toml | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml b/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml index d3172260426..9ba594e642d 100644 --- a/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml +++ b/firebase-dataconnect/gradleplugin/gradle/libs.versions.toml @@ -1,10 +1,8 @@ [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/plugin/build.gradle.kts b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts index 95b578dabd3..d6d6014abc2 100644 --- a/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts +++ b/firebase-dataconnect/gradleplugin/plugin/build.gradle.kts @@ -16,7 +16,7 @@ plugins { `java-gradle-plugin` - alias(libs.plugins.kotlin.jvm) + alias(firebaseLibs.plugins.kotlin.jvm) alias(libs.plugins.spotless) } diff --git a/firebase-dataconnect/gradleplugin/settings.gradle.kts b/firebase-dataconnect/gradleplugin/settings.gradle.kts index c118ef47580..6cfc97f2cab 100644 --- a/firebase-dataconnect/gradleplugin/settings.gradle.kts +++ b/firebase-dataconnect/gradleplugin/settings.gradle.kts @@ -30,6 +30,13 @@ dependencyResolutionManagement { google() mavenCentral() } + + // Reuse libs.version.toml from the main Gradle project. + versionCatalogs { + create("firebaseLibs") { + from(files("../../gradle/libs.versions.toml")) + } + } } include(":plugin") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4aa15b25443..8f7f8a6d4dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,4 +91,5 @@ kotest = ["kotest-runner", "kotest-assertions", "kotest-property", "kotest-prope playservices = ["playservices-base", "playservices-basement", "playservices-tasks"] [plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization-plugin" } From 7bab83801a21cb5255c7bd52f8c9824fd5fe49cd Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Fri, 27 Sep 2024 16:26:17 -0400 Subject: [PATCH 17/18] Introduce `FunctionCall` and `FunctionResponse` types (#6311) Their *part counter parts will now wrap them, instead of exposing the underlying structure directly. --- .../vertexai/internal/util/conversions.kt | 55 +++++++++++++------ .../com/google/firebase/vertexai/type/Part.kt | 28 ++++++++-- .../firebase/vertexai/UnarySnapshotTests.kt | 21 +++---- 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt index 59188094b2c..5a86ee8de4a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/internal/util/conversions.kt @@ -21,10 +21,6 @@ import android.graphics.BitmapFactory import android.util.Base64 import com.google.firebase.vertexai.common.client.Schema import com.google.firebase.vertexai.common.shared.FileData -import com.google.firebase.vertexai.common.shared.FunctionCall -import com.google.firebase.vertexai.common.shared.FunctionCallPart -import com.google.firebase.vertexai.common.shared.FunctionResponse -import com.google.firebase.vertexai.common.shared.FunctionResponsePart import com.google.firebase.vertexai.common.shared.InlineData import com.google.firebase.vertexai.type.BlockReason import com.google.firebase.vertexai.type.Candidate @@ -34,8 +30,12 @@ import com.google.firebase.vertexai.type.Content import com.google.firebase.vertexai.type.CountTokensResponse import com.google.firebase.vertexai.type.FileDataPart import com.google.firebase.vertexai.type.FinishReason +import com.google.firebase.vertexai.type.FunctionCall +import com.google.firebase.vertexai.type.FunctionCallPart import com.google.firebase.vertexai.type.FunctionCallingConfig import com.google.firebase.vertexai.type.FunctionDeclaration +import com.google.firebase.vertexai.type.FunctionResponse +import com.google.firebase.vertexai.type.FunctionResponsePart import com.google.firebase.vertexai.type.GenerateContentResponse import com.google.firebase.vertexai.type.GenerationConfig import com.google.firebase.vertexai.type.HarmBlockMethod @@ -59,6 +59,7 @@ import java.io.ByteArrayOutputStream import java.util.Calendar import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.json.JSONObject private const val BASE_64_FLAGS = Base64.NO_WRAP @@ -80,10 +81,10 @@ internal fun Part.toInternal(): com.google.firebase.vertexai.common.shared.Part com.google.firebase.vertexai.common.shared.InlineDataPart( InlineData(mimeType, Base64.encodeToString(inlineData, BASE_64_FLAGS)) ) - is com.google.firebase.vertexai.type.FunctionCallPart -> - FunctionCallPart(FunctionCall(name, args.orEmpty())) - is com.google.firebase.vertexai.type.FunctionResponsePart -> - FunctionResponsePart(FunctionResponse(name, response.toInternal())) + is FunctionCallPart -> + com.google.firebase.vertexai.common.shared.FunctionCallPart(functionCall.toInternal()) + is FunctionResponsePart -> + com.google.firebase.vertexai.common.shared.FunctionResponsePart(functionResponse.toInternal()) is FileDataPart -> com.google.firebase.vertexai.common.shared.FileDataPart( FileData(mimeType = mimeType, fileUri = uri) @@ -95,6 +96,15 @@ internal fun Part.toInternal(): com.google.firebase.vertexai.common.shared.Part } } +internal fun FunctionCall.toInternal() = + com.google.firebase.vertexai.common.shared.FunctionCall( + name, + args.orEmpty().mapValues { it.value.toString() } + ) + +internal fun FunctionResponse.toInternal() = + com.google.firebase.vertexai.common.shared.FunctionResponse(name, response) + internal fun SafetySetting.toInternal() = com.google.firebase.vertexai.common.shared.SafetySetting( harmCategory.toInternal(), @@ -213,16 +223,10 @@ internal fun com.google.firebase.vertexai.common.shared.Part.toPublic(): Part { InlineDataPart(inlineData.mimeType, data) } } - is FunctionCallPart -> - com.google.firebase.vertexai.type.FunctionCallPart( - functionCall.name, - functionCall.args.orEmpty(), - ) - is FunctionResponsePart -> - com.google.firebase.vertexai.type.FunctionResponsePart( - functionResponse.name, - functionResponse.response.toPublic(), - ) + is com.google.firebase.vertexai.common.shared.FunctionCallPart -> + FunctionCallPart(functionCall.toPublic()) + is com.google.firebase.vertexai.common.shared.FunctionResponsePart -> + FunctionResponsePart(functionResponse.toPublic()) is com.google.firebase.vertexai.common.shared.FileDataPart -> FileDataPart(fileData.mimeType, fileData.fileUri) else -> @@ -232,6 +236,21 @@ internal fun com.google.firebase.vertexai.common.shared.Part.toPublic(): Part { } } +internal fun com.google.firebase.vertexai.common.shared.FunctionCall.toPublic() = + FunctionCall( + name, + args.orEmpty().mapValues { + val argValue = it.value + if (argValue == null) JsonPrimitive(null) else Json.parseToJsonElement(argValue) + } + ) + +internal fun com.google.firebase.vertexai.common.shared.FunctionResponse.toPublic() = + FunctionResponse( + name, + response, + ) + internal fun com.google.firebase.vertexai.common.server.CitationSources.toPublic(): Citation { val publicationDateAsCalendar = publicationDate?.let { diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt index fff7eae7959..ea9635a2ef4 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt @@ -17,7 +17,8 @@ package com.google.firebase.vertexai.type import android.graphics.Bitmap -import org.json.JSONObject +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject /** Interface representing data sent to and received from requests. */ interface Part @@ -44,20 +45,35 @@ class ImagePart(val image: Bitmap) : Part class InlineDataPart(val mimeType: String, val inlineData: ByteArray) : Part /** - * Represents function call name and params received from requests. + * Represents a function call request from the model + * + * @param functionCall The information provided by the model to call a function. + */ +class FunctionCallPart(val functionCall: FunctionCall) : Part + +/** + * The result of calling a function as requested by the model. + * + * @param functionResponse The information to send back to the model as the result of a functions + * call. + */ +class FunctionResponsePart(val functionResponse: FunctionResponse) : Part + +/** + * The data necessary to invoke function [name] using the arguments [args]. * * @param name the name of the function to call * @param args the function parameters and values as a [Map] */ -class FunctionCallPart(val name: String, val args: Map) : Part +class FunctionCall(val name: String, val args: Map) /** - * Represents function call output to be returned to the model when it requests a function call. + * The [response] generated after calling function [name]. * * @param name the name of the called function - * @param response the response produced by the function as a [JSONObject] + * @param response the response produced by the function as a [JsonObject] */ -class FunctionResponsePart(val name: String, val response: JSONObject) : Part +class FunctionResponse(val name: String, val response: JsonObject) /** * Represents file data stored in Cloud Storage for Firebase, referenced by URI. diff --git a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/UnarySnapshotTests.kt b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/UnarySnapshotTests.kt index a2a12f632d6..50113660208 100644 --- a/firebase-vertexai/src/test/java/com/google/firebase/vertexai/UnarySnapshotTests.kt +++ b/firebase-vertexai/src/test/java/com/google/firebase/vertexai/UnarySnapshotTests.kt @@ -44,6 +44,7 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.HttpStatusCode import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonPrimitive import org.json.JSONArray import org.junit.Test @@ -350,7 +351,7 @@ internal class UnarySnapshotTests { val response = model.generateContent("prompt") val callPart = (response.candidates.first().content.parts.first() as FunctionCallPart) - callPart.args["season"] shouldBe null + callPart.functionCall.args["season"] shouldBe JsonPrimitive(null) } } @@ -367,7 +368,7 @@ internal class UnarySnapshotTests { it.parts.first().shouldBeInstanceOf() } - callPart.args["current"] shouldBe "true" + callPart.functionCall.args["current"] shouldBe JsonPrimitive(true) } } @@ -378,8 +379,8 @@ internal class UnarySnapshotTests { val response = model.generateContent("prompt") val callPart = response.functionCalls.shouldNotBeEmpty().first() - callPart.name shouldBe "current_time" - callPart.args.isEmpty() shouldBe true + callPart.functionCall.name shouldBe "current_time" + callPart.functionCall.args.isEmpty() shouldBe true } } @@ -390,9 +391,9 @@ internal class UnarySnapshotTests { val response = model.generateContent("prompt") val callPart = response.functionCalls.shouldNotBeEmpty().first() - callPart.name shouldBe "sum" - callPart.args["x"] shouldBe "4" - callPart.args["y"] shouldBe "5" + callPart.functionCall.name shouldBe "sum" + callPart.functionCall.args["x"] shouldBe JsonPrimitive(4) + callPart.functionCall.args["y"] shouldBe JsonPrimitive(5) } } @@ -405,8 +406,8 @@ internal class UnarySnapshotTests { callList.size shouldBe 3 callList.forEach { - it.name shouldBe "sum" - it.args.size shouldBe 2 + it.functionCall.name shouldBe "sum" + it.functionCall.args.size shouldBe 2 } } } @@ -420,7 +421,7 @@ internal class UnarySnapshotTests { response.text shouldBe "The sum of [1, 2, 3] is" callList.size shouldBe 2 - callList.forEach { it.args.size shouldBe 2 } + callList.forEach { it.functionCall.args.size shouldBe 2 } } } From e98a4b630e8d477935b530d8ed3d9e43dbb05635 Mon Sep 17 00:00:00 2001 From: Rodrigo Lazo Date: Fri, 27 Sep 2024 16:56:12 -0400 Subject: [PATCH 18/18] Use kotlin's explicit API in vertexAI (#6313) This option forces you to define the visibility of your code in Kotlin, ignoring the default visibility of `public` For more context, see https://kotlinlang.org/docs/whatsnew14.html#explicit-api-mode-for-library-authors https://umang91.medium.com/explicit-api-mode-kotlin-a527843d94f3 --- .../firebase-vertexai.gradle.kts | 18 +++++++ .../com/google/firebase/vertexai/Chat.kt | 17 +++--- .../firebase/vertexai/FirebaseVertexAI.kt | 16 +++--- .../firebase/vertexai/GenerativeModel.kt | 25 ++++----- .../firebase/vertexai/common/shared/Types.kt | 2 +- .../firebase/vertexai/java/ChatFutures.kt | 12 ++--- .../vertexai/java/GenerativeModelFutures.kt | 22 ++++---- .../firebase/vertexai/type/Candidate.kt | 42 +++++++-------- .../google/firebase/vertexai/type/Content.kt | 27 ++++++---- .../vertexai/type/CountTokensResponse.kt | 9 ++-- .../firebase/vertexai/type/Exceptions.kt | 34 +++++++----- .../vertexai/type/FunctionCallingConfig.kt | 12 ++--- .../vertexai/type/FunctionDeclaration.kt | 10 ++-- .../vertexai/type/GenerateContentResponse.kt | 14 ++--- .../vertexai/type/GenerationConfig.kt | 52 +++++++++---------- .../firebase/vertexai/type/HarmBlockMethod.kt | 2 +- .../vertexai/type/HarmBlockThreshold.kt | 2 +- .../firebase/vertexai/type/HarmCategory.kt | 2 +- .../firebase/vertexai/type/HarmProbability.kt | 2 +- .../firebase/vertexai/type/HarmSeverity.kt | 2 +- .../com/google/firebase/vertexai/type/Part.kt | 26 +++++----- .../firebase/vertexai/type/PromptFeedback.kt | 10 ++-- .../firebase/vertexai/type/RequestOptions.kt | 4 +- .../firebase/vertexai/type/SafetySetting.kt | 8 +-- .../google/firebase/vertexai/type/Schema.kt | 52 +++++++++++-------- .../com/google/firebase/vertexai/type/Tool.kt | 7 +-- .../firebase/vertexai/type/ToolConfig.kt | 2 +- .../firebase/vertexai/type/UsageMetadata.kt | 8 +-- 28 files changed, 243 insertions(+), 196 deletions(-) diff --git a/firebase-vertexai/firebase-vertexai.gradle.kts b/firebase-vertexai/firebase-vertexai.gradle.kts index 9bdabd0f0ce..018a41429d1 100644 --- a/firebase-vertexai/firebase-vertexai.gradle.kts +++ b/firebase-vertexai/firebase-vertexai.gradle.kts @@ -16,6 +16,9 @@ @file:Suppress("UnstableApiUsage") +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + + plugins { id("firebase-library") id("kotlin-android") @@ -66,6 +69,21 @@ android { } } +// 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" + } + } +} + + dependencies { val ktorVersion = "2.3.2" diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt index e44adea961a..59c507c47a0 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/Chat.kt @@ -42,7 +42,10 @@ import kotlinx.coroutines.flow.onEach * @param model The model to use for the interaction * @property history The previous interactions with the model */ -class Chat(private val model: GenerativeModel, val history: MutableList = ArrayList()) { +public class Chat( + private val model: GenerativeModel, + public val history: MutableList = ArrayList() +) { private var lock = Semaphore(1) /** @@ -53,7 +56,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @throws InvalidStateException if the prompt is not coming from the 'user' role * @throws InvalidStateException if the [Chat] instance has an active request. */ - suspend fun sendMessage(prompt: Content): GenerateContentResponse { + public suspend fun sendMessage(prompt: Content): GenerateContentResponse { prompt.assertComesFromUser() attemptLock() try { @@ -72,7 +75,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @param prompt The text to be converted into a single piece of [Content] to send to the model. * @throws InvalidStateException if the [Chat] instance has an active request. */ - suspend fun sendMessage(prompt: String): GenerateContentResponse { + public suspend fun sendMessage(prompt: String): GenerateContentResponse { val content = content { text(prompt) } return sendMessage(content) } @@ -83,7 +86,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @param prompt The image to be converted into a single piece of [Content] to send to the model. * @throws InvalidStateException if the [Chat] instance has an active request. */ - suspend fun sendMessage(prompt: Bitmap): GenerateContentResponse { + public suspend fun sendMessage(prompt: Bitmap): GenerateContentResponse { val content = content { image(prompt) } return sendMessage(content) } @@ -96,7 +99,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @throws InvalidStateException if the prompt is not coming from the 'user' role * @throws InvalidStateException if the [Chat] instance has an active request. */ - fun sendMessageStream(prompt: Content): Flow { + public fun sendMessageStream(prompt: Content): Flow { prompt.assertComesFromUser() attemptLock() @@ -149,7 +152,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @return A [Flow] which will emit responses as they are returned from the model. * @throws InvalidStateException if the [Chat] instance has an active request. */ - fun sendMessageStream(prompt: String): Flow { + public fun sendMessageStream(prompt: String): Flow { val content = content { text(prompt) } return sendMessageStream(content) } @@ -161,7 +164,7 @@ class Chat(private val model: GenerativeModel, val history: MutableList * @return A [Flow] which will emit responses as they are returned from the model. * @throws InvalidStateException if the [Chat] instance has an active request. */ - fun sendMessageStream(prompt: Bitmap): Flow { + public fun sendMessageStream(prompt: Bitmap): Flow { val content = content { image(prompt) } return sendMessageStream(content) } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt index c19326a1682..145dd90b121 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/FirebaseVertexAI.kt @@ -31,7 +31,7 @@ import com.google.firebase.vertexai.type.Tool import com.google.firebase.vertexai.type.ToolConfig /** Entry point for all _Vertex AI for Firebase_ functionality. */ -class FirebaseVertexAI +public class FirebaseVertexAI internal constructor( private val firebaseApp: FirebaseApp, private val location: String, @@ -51,7 +51,7 @@ internal constructor( * @param systemInstruction contains a [Content] that directs the model to behave a certain way */ @JvmOverloads - fun generativeModel( + public fun generativeModel( modelName: String, generationConfig: GenerationConfig? = null, safetySettings: List? = null, @@ -77,13 +77,13 @@ internal constructor( ) } - companion object { + public companion object { /** The [FirebaseVertexAI] instance for the default [FirebaseApp] */ @JvmStatic - val instance: FirebaseVertexAI + public val instance: FirebaseVertexAI get() = getInstance(location = "us-central1") - @JvmStatic fun getInstance(app: FirebaseApp): FirebaseVertexAI = getInstance(app) + @JvmStatic public fun getInstance(app: FirebaseApp): FirebaseVertexAI = getInstance(app) /** * Returns the [FirebaseVertexAI] instance for the provided [FirebaseApp] and [location] @@ -93,7 +93,7 @@ internal constructor( */ @JvmStatic @JvmOverloads - fun getInstance(app: FirebaseApp = Firebase.app, location: String): FirebaseVertexAI { + public fun getInstance(app: FirebaseApp = Firebase.app, location: String): FirebaseVertexAI { val multiResourceComponent = app[FirebaseVertexAIMultiResourceComponent::class.java] return multiResourceComponent.get(location) } @@ -101,11 +101,11 @@ internal constructor( } /** Returns the [FirebaseVertexAI] instance of the default [FirebaseApp]. */ -val Firebase.vertexAI: FirebaseVertexAI +public val Firebase.vertexAI: FirebaseVertexAI get() = FirebaseVertexAI.instance /** Returns the [FirebaseVertexAI] instance of a given [FirebaseApp]. */ -fun Firebase.vertexAI( +public fun Firebase.vertexAI( app: FirebaseApp = Firebase.app, location: String = "us-central1" ): FirebaseVertexAI = FirebaseVertexAI.getInstance(app, location) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt index 50769073fa5..3f3fc260c71 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/GenerativeModel.kt @@ -50,7 +50,7 @@ import kotlinx.coroutines.tasks.await /** * A controller for communicating with the API of a given multimodal model (for example, Gemini). */ -class GenerativeModel +public class GenerativeModel internal constructor( private val modelName: String, private val generationConfig: GenerationConfig? = null, @@ -128,7 +128,7 @@ internal constructor( * @return A [GenerateContentResponse]. Function should be called within a suspend context to * properly manage concurrency. */ - suspend fun generateContent(vararg prompt: Content): GenerateContentResponse = + public suspend fun generateContent(vararg prompt: Content): GenerateContentResponse = try { controller.generateContent(constructRequest(*prompt)).toPublic().validate() } catch (e: Throwable) { @@ -141,7 +141,7 @@ internal constructor( * @param prompt [Content] to send to the model. * @return A [Flow] which will emit responses as they are returned from the model. */ - fun generateContentStream(vararg prompt: Content): Flow = + public fun generateContentStream(vararg prompt: Content): Flow = controller .generateContentStream(constructRequest(*prompt)) .catch { throw FirebaseVertexAIException.from(it) } @@ -154,7 +154,7 @@ internal constructor( * @return A [GenerateContentResponse] after some delay. Function should be called within a * suspend context to properly manage concurrency. */ - suspend fun generateContent(prompt: String): GenerateContentResponse = + public suspend fun generateContent(prompt: String): GenerateContentResponse = generateContent(content { text(prompt) }) /** @@ -163,7 +163,7 @@ internal constructor( * @param prompt The text to be converted into a single piece of [Content] to send to the model. * @return A [Flow] which will emit responses as they are returned from the model. */ - fun generateContentStream(prompt: String): Flow = + public fun generateContentStream(prompt: String): Flow = generateContentStream(content { text(prompt) }) /** @@ -173,7 +173,7 @@ internal constructor( * @return A [GenerateContentResponse] after some delay. Function should be called within a * suspend context to properly manage concurrency. */ - suspend fun generateContent(prompt: Bitmap): GenerateContentResponse = + public suspend fun generateContent(prompt: Bitmap): GenerateContentResponse = generateContent(content { image(prompt) }) /** @@ -182,11 +182,12 @@ internal constructor( * @param prompt The image to be converted into a single piece of [Content] to send to the model. * @return A [Flow] which will emit responses as they are returned from the model. */ - fun generateContentStream(prompt: Bitmap): Flow = + public fun generateContentStream(prompt: Bitmap): Flow = generateContentStream(content { image(prompt) }) /** Creates a [Chat] instance which internally tracks the ongoing conversation with the model */ - fun startChat(history: List = emptyList()): Chat = Chat(this, history.toMutableList()) + public fun startChat(history: List = emptyList()): Chat = + Chat(this, history.toMutableList()) /** * Counts the amount of tokens in a prompt. @@ -194,7 +195,7 @@ internal constructor( * @param prompt A group of [Content] to count tokens of. * @return A [CountTokensResponse] containing the amount of tokens in the prompt. */ - suspend fun countTokens(vararg prompt: Content): CountTokensResponse { + public suspend fun countTokens(vararg prompt: Content): CountTokensResponse { try { return controller.countTokens(constructCountTokensRequest(*prompt)).toPublic() } catch (e: Throwable) { @@ -208,7 +209,7 @@ internal constructor( * @param prompt The text to be converted to a single piece of [Content] to count the tokens of. * @return A [CountTokensResponse] containing the amount of tokens in the prompt. */ - suspend fun countTokens(prompt: String): CountTokensResponse { + public suspend fun countTokens(prompt: String): CountTokensResponse { return countTokens(content { text(prompt) }) } @@ -218,7 +219,7 @@ internal constructor( * @param prompt The image to be converted to a single piece of [Content] to count the tokens of. * @return A [CountTokensResponse] containing the amount of tokens in the prompt. */ - suspend fun countTokens(prompt: Bitmap): CountTokensResponse { + public suspend fun countTokens(prompt: Bitmap): CountTokensResponse { return countTokens(content { image(prompt) }) } @@ -247,7 +248,7 @@ internal constructor( ?.let { throw ResponseStoppedException(this) } } - companion object { + private companion object { private val TAG = GenerativeModel::class.java.simpleName } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt index b70603c5de0..f6c1bc22b88 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/common/shared/Types.kt @@ -41,7 +41,7 @@ internal enum class HarmCategory { @SerialName("HARM_CATEGORY_DANGEROUS_CONTENT") DANGEROUS_CONTENT } -typealias Base64 = String +internal typealias Base64 = String @ExperimentalSerializationApi @Serializable diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/ChatFutures.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/ChatFutures.kt index ee20e462be5..d6b1a4e5e22 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/ChatFutures.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/ChatFutures.kt @@ -29,7 +29,7 @@ import org.reactivestreams.Publisher * * @see from */ -abstract class ChatFutures internal constructor() { +public abstract class ChatFutures internal constructor() { /** * Generates a response from the backend with the provided [Content], and any previous ones @@ -37,17 +37,17 @@ abstract class ChatFutures internal constructor() { * * @param prompt A [Content] to send to the model. */ - abstract fun sendMessage(prompt: Content): ListenableFuture + public abstract fun sendMessage(prompt: Content): ListenableFuture /** * Generates a streaming response from the backend with the provided [Content]. * * @param prompt A [Content] to send to the model. */ - abstract fun sendMessageStream(prompt: Content): Publisher + public abstract fun sendMessageStream(prompt: Content): Publisher /** Returns the [Chat] instance that was used to create this instance */ - abstract fun getChat(): Chat + public abstract fun getChat(): Chat private class FuturesImpl(private val chat: Chat) : ChatFutures() { override fun sendMessage(prompt: Content): ListenableFuture = @@ -59,9 +59,9 @@ abstract class ChatFutures internal constructor() { override fun getChat(): Chat = chat } - companion object { + public companion object { /** @return a [ChatFutures] created around the provided [Chat] */ - @JvmStatic fun from(chat: Chat): ChatFutures = FuturesImpl(chat) + @JvmStatic public fun from(chat: Chat): ChatFutures = FuturesImpl(chat) } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/GenerativeModelFutures.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/GenerativeModelFutures.kt index 1144df72d1a..fe43e0b69a2 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/GenerativeModelFutures.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/java/GenerativeModelFutures.kt @@ -31,41 +31,45 @@ import org.reactivestreams.Publisher * * @see from */ -abstract class GenerativeModelFutures internal constructor() { +public abstract class GenerativeModelFutures internal constructor() { /** * Generates a response from the backend with the provided [Content]. * * @param prompt A group of [Content] to send to the model. */ - abstract fun generateContent(vararg prompt: Content): ListenableFuture + public abstract fun generateContent( + vararg prompt: Content + ): ListenableFuture /** * Generates a streaming response from the backend with the provided [Content]. * * @param prompt A group of [Content] to send to the model. */ - abstract fun generateContentStream(vararg prompt: Content): Publisher + public abstract fun generateContentStream( + vararg prompt: Content + ): Publisher /** * Counts the number of tokens used in a prompt. * * @param prompt A group of [Content] to count tokens of. */ - abstract fun countTokens(vararg prompt: Content): ListenableFuture + public abstract fun countTokens(vararg prompt: Content): ListenableFuture /** Creates a chat instance which internally tracks the ongoing conversation with the model */ - abstract fun startChat(): ChatFutures + public abstract fun startChat(): ChatFutures /** * Creates a chat instance which internally tracks the ongoing conversation with the model * * @param history an existing history of context to use as a starting point */ - abstract fun startChat(history: List): ChatFutures + public abstract fun startChat(history: List): ChatFutures /** Returns the [GenerativeModel] instance that was used to create this object */ - abstract fun getGenerativeModel(): GenerativeModel + public abstract fun getGenerativeModel(): GenerativeModel private class FuturesImpl(private val model: GenerativeModel) : GenerativeModelFutures() { override fun generateContent( @@ -86,9 +90,9 @@ abstract class GenerativeModelFutures internal constructor() { override fun getGenerativeModel(): GenerativeModel = model } - companion object { + public companion object { /** @return a [GenerativeModelFutures] created around the provided [GenerativeModel] */ - @JvmStatic fun from(model: GenerativeModel): GenerativeModelFutures = FuturesImpl(model) + @JvmStatic public fun from(model: GenerativeModel): GenerativeModelFutures = FuturesImpl(model) } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt index 5538c24e139..a5ac3d83716 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Candidate.kt @@ -19,23 +19,23 @@ package com.google.firebase.vertexai.type import java.util.Calendar /** A response generated by the model. */ -class Candidate +public class Candidate internal constructor( - val content: Content, - val safetyRatings: List, - val citationMetadata: CitationMetadata?, - val finishReason: FinishReason? + public val content: Content, + public val safetyRatings: List, + public val citationMetadata: CitationMetadata?, + public val finishReason: FinishReason? ) /** Safety rating corresponding to a generated content. */ -class SafetyRating +public class SafetyRating internal constructor( - val category: HarmCategory, - val probability: HarmProbability, - val probabilityScore: Float = 0f, - val blocked: Boolean? = null, - val severity: HarmSeverity? = null, - val severityScore: Float? = null + public val category: HarmCategory, + public val probability: HarmProbability, + public val probabilityScore: Float = 0f, + public val blocked: Boolean? = null, + public val severity: HarmSeverity? = null, + public val severityScore: Float? = null ) /** @@ -44,7 +44,7 @@ internal constructor( * @property citations A list of individual cited sources and the parts of the content to which they * apply. */ -class CitationMetadata internal constructor(val citations: List) +public class CitationMetadata internal constructor(public val citations: List) /** * Provides citation information for sourcing of content provided by the model between a given @@ -59,18 +59,18 @@ class CitationMetadata internal constructor(val citations: List) * @property license The license the cited source work is distributed under, if specified. * @property publicationDate Publication date of the attribution, if available. */ -class Citation +public class Citation internal constructor( - val title: String? = null, - val startIndex: Int = 0, - val endIndex: Int, - val uri: String? = null, - val license: String? = null, - val publicationDate: Calendar? = null + public val title: String? = null, + public val startIndex: Int = 0, + public val endIndex: Int, + public val uri: String? = null, + public val license: String? = null, + public val publicationDate: Calendar? = null ) /** The reason for content finishing. */ -enum class FinishReason { +public enum class FinishReason { /** A new and not yet supported value. */ UNKNOWN, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt index 89f07cbb20a..74bcf8e5f7d 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Content.kt @@ -26,46 +26,51 @@ import android.graphics.Bitmap * * @see content */ -class Content @JvmOverloads constructor(val role: String? = "user", val parts: List) { +public class Content +@JvmOverloads +constructor(public val role: String? = "user", public val parts: List) { /** Builder class to facilitate constructing complex [Content] objects. */ - class Builder { + public class Builder { /** The producer of the content. By default, it's "user". */ - var role: String? = "user" + public var role: String? = "user" /** * Mutable list of [Part] comprising a single [Content]. * * Prefer using the provided helper methods over adding elements to the list directly. */ - var parts: MutableList = arrayListOf() + public var parts: MutableList = arrayListOf() /** Adds a new [Part] to [parts]. */ - @JvmName("addPart") fun part(data: T) = apply { parts.add(data) } + @JvmName("addPart") + public fun part(data: T): Content.Builder = apply { parts.add(data) } /** Wraps the provided text inside a [TextPart] and adds it to [parts] list. */ - @JvmName("addText") fun text(text: String) = part(TextPart(text)) + @JvmName("addText") public fun text(text: String): Content.Builder = part(TextPart(text)) /** * Wraps the provided [bytes] and [mimeType] inside a [InlineDataPart] and adds it to the * [parts] list. */ @JvmName("addInlineData") - fun inlineData(mimeType: String, bytes: ByteArray) = part(InlineDataPart(mimeType, bytes)) + public fun inlineData(mimeType: String, bytes: ByteArray): Content.Builder = + part(InlineDataPart(mimeType, bytes)) /** Wraps the provided [image] inside an [ImagePart] and adds it to the [parts] list. */ - @JvmName("addImage") fun image(image: Bitmap) = part(ImagePart(image)) + @JvmName("addImage") public fun image(image: Bitmap): Content.Builder = part(ImagePart(image)) /** * Wraps the provided Google Cloud Storage for Firebase [uri] and [mimeType] inside a * [FileDataPart] and adds it to the [parts] list. */ @JvmName("addFileData") - fun fileData(uri: String, mimeType: String) = part(FileDataPart(uri, mimeType)) + public fun fileData(uri: String, mimeType: String): Content.Builder = + part(FileDataPart(uri, mimeType)) /** Returns a new [Content] using the defined [role] and [parts]. */ - fun build(): Content = Content(role, parts) + public fun build(): Content = Content(role, parts) } } @@ -81,7 +86,7 @@ class Content @JvmOverloads constructor(val role: String? = "user", val parts: L * ) * ``` */ -fun content(role: String? = "user", init: Content.Builder.() -> Unit): Content { +public fun content(role: String? = "user", init: Content.Builder.() -> Unit): Content { val builder = Content.Builder() builder.role = role builder.init() diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt index ac4ee804715..cb8b17009b1 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/CountTokensResponse.kt @@ -23,8 +23,11 @@ package com.google.firebase.vertexai.type * @property totalBillableCharacters A count of the characters that are billable in the input, if * available. */ -class CountTokensResponse(val totalTokens: Int, val totalBillableCharacters: Int? = null) { - operator fun component1() = totalTokens +public class CountTokensResponse( + public val totalTokens: Int, + public val totalBillableCharacters: Int? = null +) { + public operator fun component1(): Int = totalTokens - operator fun component2() = totalBillableCharacters + public operator fun component2(): Int? = totalBillableCharacters } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Exceptions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Exceptions.kt index f841da4432a..cc78e8a9168 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Exceptions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Exceptions.kt @@ -22,10 +22,10 @@ import com.google.firebase.vertexai.internal.util.toPublic import kotlinx.coroutines.TimeoutCancellationException /** Parent class for any errors that occur from the [FirebaseVertexAI] SDK. */ -sealed class FirebaseVertexAIException(message: String, cause: Throwable? = null) : +public sealed class FirebaseVertexAIException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) { - companion object { + internal companion object { /** * Converts a [Throwable] to a [FirebaseVertexAIException]. @@ -33,7 +33,7 @@ sealed class FirebaseVertexAIException(message: String, cause: Throwable? = null * Will populate default messages as expected, and propagate the provided [cause] through the * resulting exception. */ - fun from(cause: Throwable): FirebaseVertexAIException = + internal fun from(cause: Throwable): FirebaseVertexAIException = when (cause) { is FirebaseVertexAIException -> cause is FirebaseCommonAIException -> @@ -68,15 +68,15 @@ sealed class FirebaseVertexAIException(message: String, cause: Throwable? = null } /** Something went wrong while trying to deserialize a response from the server. */ -class SerializationException(message: String, cause: Throwable? = null) : +public class SerializationException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** The server responded with a non 200 response code. */ -class ServerException(message: String, cause: Throwable? = null) : +public class ServerException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** The server responded that the API Key is not valid. */ -class InvalidAPIKeyException(message: String, cause: Throwable? = null) : +public class InvalidAPIKeyException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** @@ -87,7 +87,10 @@ class InvalidAPIKeyException(message: String, cause: Throwable? = null) : * @property response the full server response for the request. */ // TODO(rlazo): Add secondary constructor to pass through the message? -class PromptBlockedException(val response: GenerateContentResponse, cause: Throwable? = null) : +public class PromptBlockedException( + public val response: GenerateContentResponse, + cause: Throwable? = null +) : FirebaseVertexAIException( "Prompt was blocked: ${response.promptFeedback?.blockReason?.name}", cause, @@ -101,7 +104,7 @@ class PromptBlockedException(val response: GenerateContentResponse, cause: Throw * (countries and territories) where the API is available. */ // TODO(rlazo): Add secondary constructor to pass through the message? -class UnsupportedUserLocationException(cause: Throwable? = null) : +public class UnsupportedUserLocationException(cause: Throwable? = null) : FirebaseVertexAIException("User location is not supported for the API use.", cause) /** @@ -109,7 +112,7 @@ class UnsupportedUserLocationException(cause: Throwable? = null) : * * Usually indicative of consumer error. */ -class InvalidStateException(message: String, cause: Throwable? = null) : +public class InvalidStateException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** @@ -117,7 +120,10 @@ class InvalidStateException(message: String, cause: Throwable? = null) : * * @property response the full server response for the request */ -class ResponseStoppedException(val response: GenerateContentResponse, cause: Throwable? = null) : +public class ResponseStoppedException( + public val response: GenerateContentResponse, + cause: Throwable? = null +) : FirebaseVertexAIException( "Content generation stopped. Reason: ${response.candidates.first().finishReason?.name}", cause, @@ -128,7 +134,7 @@ class ResponseStoppedException(val response: GenerateContentResponse, cause: Thr * * Usually occurs due to a user specified [timeout][RequestOptions.timeout]. */ -class RequestTimeoutException(message: String, cause: Throwable? = null) : +public class RequestTimeoutException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** @@ -137,7 +143,7 @@ class RequestTimeoutException(message: String, cause: Throwable? = null) : * For a list of valid locations, see * [Vertex AI locations.](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#available-regions) */ -class InvalidLocationException(location: String, cause: Throwable? = null) : +public class InvalidLocationException(location: String, cause: Throwable? = null) : FirebaseVertexAIException("Invalid location \"${location}\"", cause) /** @@ -145,9 +151,9 @@ class InvalidLocationException(location: String, cause: Throwable? = null) : * in the * [Firebase documentation.](https://firebase.google.com/docs/vertex-ai/faq-and-troubleshooting#required-apis) */ -class ServiceDisabledException(message: String, cause: Throwable? = null) : +public class ServiceDisabledException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) /** Catch all case for exceptions not explicitly expected. */ -class UnknownException(message: String, cause: Throwable? = null) : +public class UnknownException(message: String, cause: Throwable? = null) : FirebaseVertexAIException(message, cause) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt index ba09940c49c..30021c0fac9 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionCallingConfig.kt @@ -16,8 +16,6 @@ package com.google.firebase.vertexai.type -import com.google.firebase.vertexai.common.client.FunctionCallingConfig - /** * Contains configuration for function calling from the model. This can be used to force function * calling predictions or disable them. @@ -27,7 +25,7 @@ import com.google.firebase.vertexai.common.client.FunctionCallingConfig * should match [FunctionDeclaration.name]. With [Mode.ANY], model will predict a function call from * the set of function names provided. */ -class FunctionCallingConfig +public class FunctionCallingConfig internal constructor( internal val mode: Mode, internal val allowedFunctionNames: List? = null @@ -51,23 +49,23 @@ internal constructor( NONE } - companion object { + public companion object { /** * The default behavior for function calling. The model calls functions to answer queries at its * discretion */ - @JvmStatic fun auto() = FunctionCallingConfig(Mode.AUTO) + @JvmStatic public fun auto(): FunctionCallingConfig = FunctionCallingConfig(Mode.AUTO) /** The model always predicts a provided function call to answer every query. */ @JvmStatic @JvmOverloads - fun any(allowedFunctionNames: List? = null) = + public fun any(allowedFunctionNames: List? = null): FunctionCallingConfig = FunctionCallingConfig(Mode.ANY, allowedFunctionNames) /** * The model will never predict a function call to answer a query. This can also be achieved by * not passing any tools to the model. */ - @JvmStatic fun none() = FunctionCallingConfig(Mode.NONE) + @JvmStatic public fun none(): FunctionCallingConfig = FunctionCallingConfig(Mode.NONE) } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt index f1c0bbd0090..119a36d3eab 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/FunctionDeclaration.kt @@ -36,11 +36,11 @@ package com.google.firebase.vertexai.type * @param optionalParameters A list of parameters that can be omitted. * @see Schema */ -class FunctionDeclaration( - val name: String, - val description: String, - val parameters: Map, - val optionalParameters: List = emptyList(), +public class FunctionDeclaration( + internal val name: String, + internal val description: String, + internal val parameters: Map, + internal val optionalParameters: List = emptyList(), ) { internal val schema: Schema = Schema.obj(properties = parameters, optionalProperties = optionalParameters, nullable = false) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerateContentResponse.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerateContentResponse.kt index 83cbcf50229..61eb9218f33 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerateContentResponse.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerateContentResponse.kt @@ -25,25 +25,25 @@ import android.util.Log * @property promptFeedback optional feedback for the given prompt. When streaming, it's only * populated in the first response. */ -class GenerateContentResponse( - val candidates: List, - val promptFeedback: PromptFeedback?, - val usageMetadata: UsageMetadata?, +public class GenerateContentResponse( + public val candidates: List, + public val promptFeedback: PromptFeedback?, + public val usageMetadata: UsageMetadata?, ) { /** Convenience field representing all the text parts in the response, if they exists. */ - val text: String? by lazy { + public val text: String? by lazy { candidates.first().content.parts.filterIsInstance().joinToString(" ") { it.text } } /** Convenience field to get all the function call parts in the request, if they exist */ - val functionCalls: List by lazy { + public val functionCalls: List by lazy { candidates.first().content.parts.filterIsInstance() } /** * Convenience field representing the first function response part in the response, if it exists. */ - val functionResponse: FunctionResponsePart? by lazy { firstPartAs() } + public val functionResponse: FunctionResponsePart? by lazy { firstPartAs() } private inline fun firstPartAs(): T? { if (candidates.isEmpty()) { diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt index 16d7554ece5..8bf8d7a1ac7 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/GenerationConfig.kt @@ -73,18 +73,18 @@ package com.google.firebase.vertexai.type * [Control generated output](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/control-generated-output) * guide for more details. */ -class GenerationConfig +public class GenerationConfig private constructor( - val temperature: Float?, - val topK: Int?, - val topP: Float?, - val candidateCount: Int?, - val maxOutputTokens: Int?, - val presencePenalty: Float?, - val frequencyPenalty: Float?, - val stopSequences: List?, - val responseMimeType: String?, - val responseSchema: Schema?, + internal val temperature: Float?, + internal val topK: Int?, + internal val topP: Float?, + internal val candidateCount: Int?, + internal val maxOutputTokens: Int?, + internal val presencePenalty: Float?, + internal val frequencyPenalty: Float?, + internal val stopSequences: List?, + internal val responseMimeType: String?, + internal val responseSchema: Schema?, ) { /** @@ -114,20 +114,20 @@ private constructor( * @property responseSchema See [GenerationConfig.responseSchema]. * @see [generationConfig] */ - class Builder { - @JvmField var temperature: Float? = null - @JvmField var topK: Int? = null - @JvmField var topP: Float? = null - @JvmField var candidateCount: Int? = null - @JvmField var maxOutputTokens: Int? = null - @JvmField var presencePenalty: Float? = null - @JvmField var frequencyPenalty: Float? = null - @JvmField var stopSequences: List? = null - @JvmField var responseMimeType: String? = null - @JvmField var responseSchema: Schema? = null + public class Builder { + @JvmField public var temperature: Float? = null + @JvmField public var topK: Int? = null + @JvmField public var topP: Float? = null + @JvmField public var candidateCount: Int? = null + @JvmField public var maxOutputTokens: Int? = null + @JvmField public var presencePenalty: Float? = null + @JvmField public var frequencyPenalty: Float? = null + @JvmField public var stopSequences: List? = null + @JvmField public var responseMimeType: String? = null + @JvmField public var responseSchema: Schema? = null /** Create a new [GenerationConfig] with the attached arguments. */ - fun build() = + public fun build(): GenerationConfig = GenerationConfig( temperature = temperature, topK = topK, @@ -142,7 +142,7 @@ private constructor( ) } - companion object { + public companion object { /** * Alternative casing for [GenerationConfig.Builder]: @@ -150,7 +150,7 @@ private constructor( * val config = GenerationConfig.builder() * ``` */ - fun builder() = Builder() + public fun builder(): Builder = Builder() } } @@ -169,7 +169,7 @@ private constructor( * } * ``` */ -fun generationConfig(init: GenerationConfig.Builder.() -> Unit): GenerationConfig { +public fun generationConfig(init: GenerationConfig.Builder.() -> Unit): GenerationConfig { val builder = GenerationConfig.builder() builder.init() return builder.build() diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt index b4a1fcd17ff..032f5353c97 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockMethod.kt @@ -20,7 +20,7 @@ package com.google.firebase.vertexai.type * Specifies how the block method computes the score that will be compared against the * [HarmBlockThreshold] in [SafetySetting]. */ -enum class HarmBlockMethod { +public enum class HarmBlockMethod { /** * The harm block method uses both probability and severity scores. See [HarmSeverity] and * [HarmProbability]. diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt index ade2d1a9513..162ec2a7a1f 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmBlockThreshold.kt @@ -19,7 +19,7 @@ package com.google.firebase.vertexai.type /** * Represents the threshold for some [HarmCategory] that is allowed and blocked by [SafetySetting]. */ -enum class HarmBlockThreshold { +public enum class HarmBlockThreshold { /** Content with negligible harm is allowed. */ LOW_AND_ABOVE, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmCategory.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmCategory.kt index 68473f98187..40d104b158b 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmCategory.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmCategory.kt @@ -17,7 +17,7 @@ package com.google.firebase.vertexai.type /** Category for a given harm rating. */ -enum class HarmCategory { +public enum class HarmCategory { /** A new and not yet supported value. */ UNKNOWN, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt index 56ab86e4707..6f877dacc8d 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmProbability.kt @@ -17,7 +17,7 @@ package com.google.firebase.vertexai.type /** Represents the probability that some [HarmCategory] is applicable in a [SafetyRating]. */ -enum class HarmProbability { +public enum class HarmProbability { /** A new and not yet supported value. */ UNKNOWN, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt index 8e3f0d37c9a..a5526a66db2 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/HarmSeverity.kt @@ -17,7 +17,7 @@ package com.google.firebase.vertexai.type /** Represents the severity of a [HarmCategory] being applicable in a [SafetyRating]. */ -enum class HarmSeverity { +public enum class HarmSeverity { /** A new and not yet supported value. */ UNKNOWN, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt index ea9635a2ef4..556e518534a 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Part.kt @@ -21,10 +21,10 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject /** Interface representing data sent to and received from requests. */ -interface Part +public interface Part /** Represents text or string based data sent to and received from requests. */ -class TextPart(val text: String) : Part +public class TextPart(public val text: String) : Part /** * Represents image data sent to and received from requests. When this is sent to the server it is @@ -32,7 +32,7 @@ class TextPart(val text: String) : Part * * @param image [Bitmap] to convert into a [Part] */ -class ImagePart(val image: Bitmap) : Part +public class ImagePart(public val image: Bitmap) : Part /** * Represents binary data with an associated MIME type sent to and received from requests. @@ -42,14 +42,14 @@ class ImagePart(val image: Bitmap) : Part * . * @param inlineData the binary data as a [ByteArray] */ -class InlineDataPart(val mimeType: String, val inlineData: ByteArray) : Part +public class InlineDataPart(public val mimeType: String, public val inlineData: ByteArray) : Part /** * Represents a function call request from the model * * @param functionCall The information provided by the model to call a function. */ -class FunctionCallPart(val functionCall: FunctionCall) : Part +public class FunctionCallPart(public val functionCall: FunctionCall) : Part /** * The result of calling a function as requested by the model. @@ -57,7 +57,7 @@ class FunctionCallPart(val functionCall: FunctionCall) : Part * @param functionResponse The information to send back to the model as the result of a functions * call. */ -class FunctionResponsePart(val functionResponse: FunctionResponse) : Part +public class FunctionResponsePart(public val functionResponse: FunctionResponse) : Part /** * The data necessary to invoke function [name] using the arguments [args]. @@ -65,7 +65,7 @@ class FunctionResponsePart(val functionResponse: FunctionResponse) : Part * @param name the name of the function to call * @param args the function parameters and values as a [Map] */ -class FunctionCall(val name: String, val args: Map) +public class FunctionCall(public val name: String, public val args: Map) /** * The [response] generated after calling function [name]. @@ -73,7 +73,7 @@ class FunctionCall(val name: String, val args: Map) * @param name the name of the called function * @param response the response produced by the function as a [JsonObject] */ -class FunctionResponse(val name: String, val response: JsonObject) +public class FunctionResponse(public val name: String, public val response: JsonObject) /** * Represents file data stored in Cloud Storage for Firebase, referenced by URI. @@ -83,16 +83,16 @@ class FunctionResponse(val name: String, val response: JsonObject) * @param mimeType an IANA standard MIME type. For supported MIME type values see the * [Firebase documentation](https://firebase.google.com/docs/vertex-ai/input-file-requirements). */ -class FileDataPart(val uri: String, val mimeType: String) : Part +public class FileDataPart(public val uri: String, public val mimeType: String) : Part /** Returns the part as a [String] if it represents text, and null otherwise */ -fun Part.asTextOrNull(): String? = (this as? TextPart)?.text +public fun Part.asTextOrNull(): String? = (this as? TextPart)?.text /** Returns the part as a [Bitmap] if it represents an image, and null otherwise */ -fun Part.asImageOrNull(): Bitmap? = (this as? ImagePart)?.image +public fun Part.asImageOrNull(): Bitmap? = (this as? ImagePart)?.image /** Returns the part as a [InlineDataPart] if it represents inline data, and null otherwise */ -fun Part.asInlineDataPartOrNull(): InlineDataPart? = this as? InlineDataPart +public fun Part.asInlineDataPartOrNull(): InlineDataPart? = this as? InlineDataPart /** Returns the part as a [FileDataPart] if it represents a file, and null otherwise */ -fun Part.asFileDataOrNull(): FileDataPart? = this as? FileDataPart +public fun Part.asFileDataOrNull(): FileDataPart? = this as? FileDataPart diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt index 3043dd01632..8486ef76f75 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/PromptFeedback.kt @@ -23,14 +23,14 @@ package com.google.firebase.vertexai.type * @param safetyRatings A list of relevant [SafetyRating]. * @param blockReasonMessage A message describing the reason that content was blocked, if any. */ -class PromptFeedback( - val blockReason: BlockReason?, - val safetyRatings: List, - val blockReasonMessage: String? +public class PromptFeedback( + public val blockReason: BlockReason?, + public val safetyRatings: List, + public val blockReasonMessage: String? ) /** Describes why content was blocked. */ -enum class BlockReason { +public enum class BlockReason { /** A new and not yet supported value. */ UNKNOWN, diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt index dbfbc5b9367..9aa648b6d07 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/RequestOptions.kt @@ -22,7 +22,7 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration /** Configurable options unique to how requests to the backend are performed. */ -class RequestOptions +public class RequestOptions internal constructor( internal val timeout: Duration, internal val endpoint: String = "https://firebasevertexai.googleapis.com", @@ -36,7 +36,7 @@ internal constructor( * the first request to first response. */ @JvmOverloads - constructor( + public constructor( timeoutInMillis: Long = 180.seconds.inWholeMilliseconds ) : this(timeout = timeoutInMillis.toDuration(DurationUnit.MILLISECONDS)) } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt index 1e0bc763097..752353f8e4f 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/SafetySetting.kt @@ -24,8 +24,8 @@ package com.google.firebase.vertexai.type * @param threshold The threshold form harm allowable. * @param method Specify if the threshold is used for probability or severity score. */ -class SafetySetting( - val harmCategory: HarmCategory, - val threshold: HarmBlockThreshold, - val method: HarmBlockMethod = HarmBlockMethod.PROBABILITY +public class SafetySetting( + internal val harmCategory: HarmCategory, + internal val threshold: HarmBlockThreshold, + internal val method: HarmBlockMethod = HarmBlockMethod.PROBABILITY ) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt index 4d041c917d2..f599e7d3b81 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Schema.kt @@ -16,27 +16,27 @@ package com.google.firebase.vertexai.type -sealed class StringFormat(val value: String) { - class Custom(format: String) : StringFormat(format) +public sealed class StringFormat(public val value: String) { + public class Custom(format: String) : StringFormat(format) } /** Represents a schema */ -class Schema +public class Schema internal constructor( - val type: String, - val description: String? = null, - val format: String? = null, - val nullable: Boolean? = null, - val enum: List? = null, - val properties: Map? = null, - val required: List? = null, - val items: Schema? = null, + public val type: String, + public val description: String? = null, + public val format: String? = null, + public val nullable: Boolean? = null, + public val enum: List? = null, + public val properties: Map? = null, + public val required: List? = null, + public val items: Schema? = null, ) { - companion object { + public companion object { /** Returns a schema for a boolean */ @JvmStatic - fun boolean(description: String? = null, nullable: Boolean = false) = + public fun boolean(description: String? = null, nullable: Boolean = false): Schema = Schema( description = description, nullable = nullable, @@ -51,7 +51,7 @@ internal constructor( */ @JvmStatic @JvmName("numInt") - fun integer(description: String? = null, nullable: Boolean = false) = + public fun integer(description: String? = null, nullable: Boolean = false): Schema = Schema( description = description, format = "int32", @@ -67,7 +67,7 @@ internal constructor( */ @JvmStatic @JvmName("numLong") - fun long(description: String? = null, nullable: Boolean = false) = + public fun long(description: String? = null, nullable: Boolean = false): Schema = Schema( description = description, nullable = nullable, @@ -82,7 +82,7 @@ internal constructor( */ @JvmStatic @JvmName("numDouble") - fun double(description: String? = null, nullable: Boolean = false) = + public fun double(description: String? = null, nullable: Boolean = false): Schema = Schema(description = description, nullable = nullable, type = "NUMBER", format = "double") /** @@ -93,7 +93,7 @@ internal constructor( */ @JvmStatic @JvmName("numFloat") - fun float(description: String? = null, nullable: Boolean = false) = + public fun float(description: String? = null, nullable: Boolean = false): Schema = Schema(description = description, nullable = nullable, type = "NUMBER", format = "float") /** @@ -105,11 +105,11 @@ internal constructor( */ @JvmStatic @JvmName("str") - fun string( + public fun string( description: String? = null, nullable: Boolean = false, format: StringFormat? = null - ) = + ): Schema = Schema( description = description, format = format?.value, @@ -125,7 +125,7 @@ internal constructor( * @param nullable: Whether null is a valid value for this schema */ @JvmStatic - fun obj( + public fun obj( properties: Map, optionalProperties: List = emptyList(), description: String? = null, @@ -153,7 +153,11 @@ internal constructor( * @param nullable: Whether null is a valid value for this schema */ @JvmStatic - fun array(items: Schema, description: String? = null, nullable: Boolean = false) = + public fun array( + items: Schema, + description: String? = null, + nullable: Boolean = false + ): Schema = Schema( description = description, nullable = nullable, @@ -169,7 +173,11 @@ internal constructor( * @param nullable: Whether null is a valid value for this schema */ @JvmStatic - fun enumeration(values: List, description: String? = null, nullable: Boolean = false) = + public fun enumeration( + values: List, + description: String? = null, + nullable: Boolean = false + ): Schema = Schema( description = description, format = "enum", diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt index 0400387c081..41cbf99f6c4 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/Tool.kt @@ -22,8 +22,9 @@ package com.google.firebase.vertexai.type * * @param functionDeclarations The set of functions that this tool allows the model access to */ -class Tool internal constructor(internal val functionDeclarations: List?) { - companion object { +public class Tool +internal constructor(internal val functionDeclarations: List?) { + public companion object { /** * Creates a [Tool] instance that provides the model with access to the [functionDeclarations]. @@ -31,7 +32,7 @@ class Tool internal constructor(internal val functionDeclarations: List): Tool { + public fun functionDeclarations(functionDeclarations: List): Tool { return Tool(functionDeclarations) } } diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt index 38ea3837801..7641120b2ee 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/ToolConfig.kt @@ -22,4 +22,4 @@ package com.google.firebase.vertexai.type * * @param functionCallingConfig The config for function calling */ -class ToolConfig(val functionCallingConfig: FunctionCallingConfig) +public class ToolConfig(internal val functionCallingConfig: FunctionCallingConfig) diff --git a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/UsageMetadata.kt b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/UsageMetadata.kt index 6b403e11530..21da0255cb9 100644 --- a/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/UsageMetadata.kt +++ b/firebase-vertexai/src/main/kotlin/com/google/firebase/vertexai/type/UsageMetadata.kt @@ -23,8 +23,8 @@ package com.google.firebase.vertexai.type * @param candidatesTokenCount Number of tokens in the response(s). * @param totalTokenCount Total number of tokens. */ -class UsageMetadata( - val promptTokenCount: Int, - val candidatesTokenCount: Int?, - val totalTokenCount: Int +public class UsageMetadata( + public val promptTokenCount: Int, + public val candidatesTokenCount: Int?, + public val totalTokenCount: Int )