Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions firebase-dataconnect/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
* [changed] Removed the "beta" suffix from the version of the Firebase Data
Connect Android SDK, thus graduating it from "beta" to "generally available".
([#6792](https://github.com/firebase/firebase-android-sdk/pull/6792))
* [changed] DataConnectOperationException added, enabling support for partial
errors; that is, any data that was received and/or was able to be decoded is
now available via the "response" property of the exception thrown when a
query or mutation is executed.
([#6794](https://github.com/firebase/firebase-android-sdk/pull/6794))

# 16.0.0-beta05
* [changed] Changed gRPC proto package to v1 (was v1beta).
Expand Down
41 changes: 41 additions & 0 deletions firebase-dataconnect/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,47 @@ package com.google.firebase.dataconnect {
ctor public DataConnectException(String message, Throwable? cause = null);
}

public class DataConnectOperationException extends com.google.firebase.dataconnect.DataConnectException {
ctor public DataConnectOperationException(String message, Throwable? cause = null, com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> response);
method public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> getResponse();
property public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> response;
}

public interface DataConnectOperationFailureResponse<Data> {
method public Data? getData();
method public java.util.List<com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo> getErrors();
method public java.util.Map<java.lang.String,java.lang.Object?>? getRawData();
method public String toString();
property public abstract Data? data;
property public abstract java.util.List<com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo> errors;
property public abstract java.util.Map<java.lang.String,java.lang.Object?>? rawData;
}

public static interface DataConnectOperationFailureResponse.ErrorInfo {
method public boolean equals(Object? other);
method public String getMessage();
method public java.util.List<com.google.firebase.dataconnect.DataConnectPathSegment> getPath();
method public int hashCode();
method public String toString();
property public abstract String message;
property public abstract java.util.List<com.google.firebase.dataconnect.DataConnectPathSegment> path;
}

public sealed interface DataConnectPathSegment {
}

@kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.Field implements com.google.firebase.dataconnect.DataConnectPathSegment {
ctor public DataConnectPathSegment.Field(String field);
method public String getField();
property public final String field;
}

@kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.ListIndex implements com.google.firebase.dataconnect.DataConnectPathSegment {
ctor public DataConnectPathSegment.ListIndex(int index);
method public int getIndex();
property public final int index;
}

public final class DataConnectSettings {
ctor public DataConnectSettings(String host = "firebasedataconnect.googleapis.com", boolean sslEnabled = true);
method public String getHost();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,14 @@ query getPersonAuth($id: String!) @auth(level: USER_ANON) {
age
}
}

query getPersonWithPartialFailure($id: String!) @auth(level: PUBLIC) {
person1: person(id: $id) { name }
person2: person(id: $id) @check(expr: "false", message: "c8azjdwz2x") { name }
}

mutation createPersonWithPartialFailure($id: String!, $name: String!) @auth(level: PUBLIC) {
person1: person_insert(data: { id: $id, name: $name })
person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) @check(expr: "false", message: "ecxpjy4qfy")
}

1 change: 1 addition & 0 deletions firebase-dataconnect/firebase-dataconnect.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ dependencies {

compileOnly(libs.javax.annotation.jsr250)
compileOnly(libs.kotlinx.datetime)
compileOnly(libs.kotlinx.serialization.json)
implementation(libs.grpc.android)
implementation(libs.grpc.kotlin.stub)
implementation(libs.grpc.okhttp)
Expand Down
31 changes: 31 additions & 0 deletions firebase-dataconnect/scripts/generateApiTxtFile.sh
Original file line number Diff line number Diff line change
@@ -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

readonly PROJECT_ROOT_DIR="$(dirname "$0")/../.."

readonly args=(
"${PROJECT_ROOT_DIR}/gradlew"
"-p"
"${PROJECT_ROOT_DIR}"
"--configure-on-demand"
"$@"
":firebase-dataconnect:generateApiTxtFile"
)

echo "${args[*]}"
exec "${args[@]}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.schemas.PersonSchema
import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.CreatePersonMutation
import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonQuery
import com.google.firebase.dataconnect.testutil.shouldSatisfy
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveAtLeastSize
import io.kotest.property.Arb
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.next
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.serializer
import org.junit.Test

class OperationExecutionErrorsIntegrationTest : DataConnectIntegrationTestBase() {

private val personSchema: PersonSchema by lazy { PersonSchema(dataConnectFactory) }
private val dataConnect: FirebaseDataConnect by lazy { personSchema.dataConnect }

@Test
fun executeQueryFailsWithNullDataNonEmptyErrors() = runTest {
val queryRef =
dataConnect.query(
operationName = GetPersonQuery.operationName,
variables = Arb.incompatibleVariables().next(rs),
dataDeserializer = serializer<GetPersonQuery.Data>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedMessageSubstringCaseSensitive = "jwdbzka4k5",
expectedCause = null,
expectedRawData = null,
expectedData = null,
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Test
fun executeMutationFailsWithNullDataNonEmptyErrors() = runTest {
val mutationRef =
dataConnect.mutation(
operationName = CreatePersonMutation.operationName,
variables = Arb.incompatibleVariables().next(rs),
dataDeserializer = serializer<CreatePersonMutation.Data>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedCause = null,
expectedRawData = null,
expectedData = null,
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Test
fun executeQueryFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest {
val id = Arb.alphanumericString().next()
val queryRef =
dataConnect.query(
operationName = GetPersonQuery.operationName,
variables = GetPersonQuery.Variables(id),
dataDeserializer = serializer<IncompatibleData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed",
expectedCause = SerializationException::class,
expectedRawData = mapOf("person" to null),
expectedData = null,
expectedErrors = emptyList(),
)
}

@Test
fun executeMutationFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest {
val id = Arb.alphanumericString().next()
val name = Arb.alphanumericString().next()
val mutationRef =
dataConnect.mutation(
operationName = CreatePersonMutation.operationName,
variables = CreatePersonMutation.Variables(id, name),
dataDeserializer = serializer<IncompatibleData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed",
expectedCause = SerializationException::class,
expectedRawData = mapOf("person_insert" to mapOf("id" to id)),
expectedData = null,
expectedErrors = emptyList(),
)
}

@Test
fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest {
val id = Arb.alphanumericString().next()
val name = Arb.alphanumericString().next()
personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute()
val queryRef =
dataConnect.query(
operationName = "getPersonWithPartialFailure",
variables = GetPersonWithPartialFailureVariables(id),
dataDeserializer = serializer<GetPersonWithPartialFailureData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedMessageSubstringCaseSensitive = "c8azjdwz2x",
expectedCause = null,
expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null),
expectedData = GetPersonWithPartialFailureData(name),
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Test
fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest {
val id = Arb.alphanumericString().next()
val name = Arb.alphanumericString().next()
val mutationRef =
dataConnect.mutation(
operationName = "createPersonWithPartialFailure",
variables = CreatePersonWithPartialFailureVariables(id = id, name = name),
dataDeserializer = serializer<CreatePersonWithPartialFailureData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedMessageSubstringCaseSensitive = "ecxpjy4qfy",
expectedCause = null,
expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null),
expectedData = CreatePersonWithPartialFailureData(id),
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Test
fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest {
val id = Arb.alphanumericString().next()
val name = Arb.alphanumericString().next()
personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute()
val queryRef =
dataConnect.query(
operationName = "getPersonWithPartialFailure",
variables = GetPersonWithPartialFailureVariables(id),
dataDeserializer = serializer<IncompatibleData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedMessageSubstringCaseSensitive = "c8azjdwz2x",
expectedCause = null,
expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null),
expectedData = null,
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Test
fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest {
val id = Arb.alphanumericString().next()
val name = Arb.alphanumericString().next()
val mutationRef =
dataConnect.mutation(
operationName = "createPersonWithPartialFailure",
variables = CreatePersonWithPartialFailureVariables(id = id, name = name),
dataDeserializer = serializer<IncompatibleData>(),
variablesSerializer = serializer(),
optionsBuilder = {},
)

val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }

exception.shouldSatisfy(
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
expectedMessageSubstringCaseSensitive = "ecxpjy4qfy",
expectedCause = null,
expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null),
expectedData = null,
errorsValidator = { it.shouldHaveAtLeastSize(1) },
)
}

@Serializable private data class IncompatibleVariables(val jwdbzka4k5: String)

@Serializable private data class IncompatibleData(val btzjhbfz7h: String)

private fun Arb.Companion.incompatibleVariables(string: Arb<String> = Arb.alphanumericString()) =
string.map { IncompatibleVariables(it) }

@Serializable private data class GetPersonWithPartialFailureVariables(val id: String)

@Serializable
private data class GetPersonWithPartialFailureData(val person1: Person, val person2: Nothing?) {
constructor(person1Name: String) : this(Person(person1Name), null)

@Serializable private data class Person(val name: String)
}

@Serializable
private data class CreatePersonWithPartialFailureVariables(val id: String, val name: String)

@Serializable
private data class CreatePersonWithPartialFailureData(
val person1: Person,
val person2: Nothing?
) {
constructor(person1Id: String) : this(Person(person1Id), null)

@Serializable private data class Person(val id: String)
}
}
Loading
Loading