Skip to content

Commit ba69975

Browse files
authored
feat: dataconnect: DataConnectOperationException added to support partial errors (#6794)
This PR adds the `DataConnectOperationException` class, a subclass of the previously-existing `DataConnectException` class. This new `DataConnectOperationException` class is thrown by invocations of `QueryRef.execute()` and `MutationRef.execute()` in the case where a response _is_ received from the backend, but the response indicates that the operation could not be executed to completion, including the case that the client SDK fails to decode the response to a higher-level object. Other kinds of errors, such as networking errors, will still be reported as they were previously. Client code can catch `DataConnectOperationException` and check its `response` property to get details about any errors that occurred and any data that was received. If the data was able to be decoded, despite the errors, then it will be available. Also the "raw" data (a `Map<String, Any?>`) property will give access to the raw, undecoded data, if any was sent from the backend. This feature is intended to support "partial errors", where an operation can partially succeed and partially fail, and the client application wants to take some special behavior in the case of a partial success. For example, suppose this database schema and connector definition: ``` type Person @table { name: String! } # Notice how both "inserts" use the same ID; this means that one of them # will necessarily fail because you can't have two rows with the same ID. mutation InsertMultiplePeople($id: UUID!, $name1: String!, $name2: String!) { person1: person_insert(data: { id: $id, name: $name1 }) person2: person_insert(data: { id: $id, name: $name2 }) } ``` Here is some code that handles the partial error that will occur if this mutation were to ever be executed: ```kt import com.google.firebase.dataconnect.DataConnectOperationException import com.google.firebase.dataconnect.DataConnectOperationFailureResponse import com.google.firebase.dataconnect.DataConnectPathSegment import com.myapp.myconnector.InsertTwoFoosWithSameIdMutation.Data suspend fun demo(id: UUID, connector: DemoConnector): Data { val result = connector.insertTwoFoosWithSameId.runCatching { execute(id) } result.onSuccess { println("Weird... inserting _both_ entries with ID $id succeeded 🤷") return@demo it.data } val exception = result.exceptionOrNull()!! if (exception !is DataConnectOperationException) { throw exception } // Print warnings messages about which of "foo1" and "foo2" failed to // be inserted by the query. This information is gleaned from the list of // errors provided in the DataConnectOperationFailureResponse. val response: DataConnectOperationFailureResponse<*> = exception.response val errors = response.errors val error1 = errors.firstOrNull { it.path == listOf(DataConnectPathSegment.Field("person1")) } if (error1 == null) { println("Inserting 1st entry with ID $id succeeded") } else { println("Inserting 1st entry with ID $id failed: ${error1.message}") } val error2 = errors.firstOrNull it.path == listOf(DataConnectPathSegment.Field("person2")) } if (error2 == null) { println("Inserting 2nd entry with ID $id succeeded") } else { println("Inserting 2nd entry with ID $id failed: ${error2.message}") } // If decoding the response was actually successful, then return // the decoded response. val data = response.data as? Data if (data != null) { return data } throw exception } ```
1 parent 75be716 commit ba69975

File tree

25 files changed

+1569
-795
lines changed

25 files changed

+1569
-795
lines changed

firebase-dataconnect/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
* [changed] Removed the "beta" suffix from the version of the Firebase Data
33
Connect Android SDK, thus graduating it from "beta" to "generally available".
44
([#6792](https://github.com/firebase/firebase-android-sdk/pull/6792))
5+
* [changed] DataConnectOperationException added, enabling support for partial
6+
errors; that is, any data that was received and/or was able to be decoded is
7+
now available via the "response" property of the exception thrown when a
8+
query or mutation is executed.
9+
([#6794](https://github.com/firebase/firebase-android-sdk/pull/6794))
510

611
# 16.0.0-beta05
712
* [changed] Changed gRPC proto package to v1 (was v1beta).

firebase-dataconnect/api.txt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,47 @@ package com.google.firebase.dataconnect {
4242
ctor public DataConnectException(String message, Throwable? cause = null);
4343
}
4444

45+
public class DataConnectOperationException extends com.google.firebase.dataconnect.DataConnectException {
46+
ctor public DataConnectOperationException(String message, Throwable? cause = null, com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> response);
47+
method public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> getResponse();
48+
property public final com.google.firebase.dataconnect.DataConnectOperationFailureResponse<? extends java.lang.Object?> response;
49+
}
50+
51+
public interface DataConnectOperationFailureResponse<Data> {
52+
method public Data? getData();
53+
method public java.util.List<com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo> getErrors();
54+
method public java.util.Map<java.lang.String,java.lang.Object?>? getRawData();
55+
method public String toString();
56+
property public abstract Data? data;
57+
property public abstract java.util.List<com.google.firebase.dataconnect.DataConnectOperationFailureResponse.ErrorInfo> errors;
58+
property public abstract java.util.Map<java.lang.String,java.lang.Object?>? rawData;
59+
}
60+
61+
public static interface DataConnectOperationFailureResponse.ErrorInfo {
62+
method public boolean equals(Object? other);
63+
method public String getMessage();
64+
method public java.util.List<com.google.firebase.dataconnect.DataConnectPathSegment> getPath();
65+
method public int hashCode();
66+
method public String toString();
67+
property public abstract String message;
68+
property public abstract java.util.List<com.google.firebase.dataconnect.DataConnectPathSegment> path;
69+
}
70+
71+
public sealed interface DataConnectPathSegment {
72+
}
73+
74+
@kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.Field implements com.google.firebase.dataconnect.DataConnectPathSegment {
75+
ctor public DataConnectPathSegment.Field(String field);
76+
method public String getField();
77+
property public final String field;
78+
}
79+
80+
@kotlin.jvm.JvmInline public static final value class DataConnectPathSegment.ListIndex implements com.google.firebase.dataconnect.DataConnectPathSegment {
81+
ctor public DataConnectPathSegment.ListIndex(int index);
82+
method public int getIndex();
83+
property public final int index;
84+
}
85+
4586
public final class DataConnectSettings {
4687
ctor public DataConnectSettings(String host = "firebasedataconnect.googleapis.com", boolean sslEnabled = true);
4788
method public String getHost();

firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,14 @@ query getPersonAuth($id: String!) @auth(level: USER_ANON) {
8888
age
8989
}
9090
}
91+
92+
query getPersonWithPartialFailure($id: String!) @auth(level: PUBLIC) {
93+
person1: person(id: $id) { name }
94+
person2: person(id: $id) @check(expr: "false", message: "c8azjdwz2x") { name }
95+
}
96+
97+
mutation createPersonWithPartialFailure($id: String!, $name: String!) @auth(level: PUBLIC) {
98+
person1: person_insert(data: { id: $id, name: $name })
99+
person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) @check(expr: "false", message: "ecxpjy4qfy")
100+
}
101+

firebase-dataconnect/emulator/emulator.sh

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@
1616

1717
set -euo pipefail
1818

19-
echo "[$0] PID=$$"
20-
21-
readonly SELF_DIR="$(dirname "$0")"
19+
export FIREBASE_DATACONNECT_POSTGRESQL_STRING='postgresql://postgres:postgres@localhost:5432?sslmode=disable'
20+
echo "[$0] export FIREBASE_DATACONNECT_POSTGRESQL_STRING='$FIREBASE_DATACONNECT_POSTGRESQL_STRING'"
2221

2322
readonly FIREBASE_ARGS=(
2423
firebase
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.dataconnect
18+
19+
import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase
20+
import com.google.firebase.dataconnect.testutil.schemas.PersonSchema
21+
import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.CreatePersonMutation
22+
import com.google.firebase.dataconnect.testutil.schemas.PersonSchema.GetPersonQuery
23+
import com.google.firebase.dataconnect.testutil.shouldSatisfy
24+
import io.kotest.assertions.throwables.shouldThrow
25+
import io.kotest.matchers.collections.shouldHaveAtLeastSize
26+
import io.kotest.property.Arb
27+
import io.kotest.property.arbitrary.map
28+
import io.kotest.property.arbitrary.next
29+
import kotlinx.coroutines.test.runTest
30+
import kotlinx.serialization.Serializable
31+
import kotlinx.serialization.SerializationException
32+
import kotlinx.serialization.serializer
33+
import org.junit.Test
34+
35+
class OperationExecutionErrorsIntegrationTest : DataConnectIntegrationTestBase() {
36+
37+
private val personSchema: PersonSchema by lazy { PersonSchema(dataConnectFactory) }
38+
private val dataConnect: FirebaseDataConnect by lazy { personSchema.dataConnect }
39+
40+
@Test
41+
fun executeQueryFailsWithNullDataNonEmptyErrors() = runTest {
42+
val queryRef =
43+
dataConnect.query(
44+
operationName = GetPersonQuery.operationName,
45+
variables = Arb.incompatibleVariables().next(rs),
46+
dataDeserializer = serializer<GetPersonQuery.Data>(),
47+
variablesSerializer = serializer(),
48+
optionsBuilder = {},
49+
)
50+
51+
val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }
52+
53+
exception.shouldSatisfy(
54+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
55+
expectedMessageSubstringCaseSensitive = "jwdbzka4k5",
56+
expectedCause = null,
57+
expectedRawData = null,
58+
expectedData = null,
59+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
60+
)
61+
}
62+
63+
@Test
64+
fun executeMutationFailsWithNullDataNonEmptyErrors() = runTest {
65+
val mutationRef =
66+
dataConnect.mutation(
67+
operationName = CreatePersonMutation.operationName,
68+
variables = Arb.incompatibleVariables().next(rs),
69+
dataDeserializer = serializer<CreatePersonMutation.Data>(),
70+
variablesSerializer = serializer(),
71+
optionsBuilder = {},
72+
)
73+
74+
val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }
75+
76+
exception.shouldSatisfy(
77+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
78+
expectedCause = null,
79+
expectedRawData = null,
80+
expectedData = null,
81+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
82+
)
83+
}
84+
85+
@Test
86+
fun executeQueryFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest {
87+
val id = Arb.alphanumericString().next()
88+
val queryRef =
89+
dataConnect.query(
90+
operationName = GetPersonQuery.operationName,
91+
variables = GetPersonQuery.Variables(id),
92+
dataDeserializer = serializer<IncompatibleData>(),
93+
variablesSerializer = serializer(),
94+
optionsBuilder = {},
95+
)
96+
97+
val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }
98+
99+
exception.shouldSatisfy(
100+
expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed",
101+
expectedCause = SerializationException::class,
102+
expectedRawData = mapOf("person" to null),
103+
expectedData = null,
104+
expectedErrors = emptyList(),
105+
)
106+
}
107+
108+
@Test
109+
fun executeMutationFailsWithNonNullDataEmptyErrorsButDecodingResponseDataFails() = runTest {
110+
val id = Arb.alphanumericString().next()
111+
val name = Arb.alphanumericString().next()
112+
val mutationRef =
113+
dataConnect.mutation(
114+
operationName = CreatePersonMutation.operationName,
115+
variables = CreatePersonMutation.Variables(id, name),
116+
dataDeserializer = serializer<IncompatibleData>(),
117+
variablesSerializer = serializer(),
118+
optionsBuilder = {},
119+
)
120+
121+
val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }
122+
123+
exception.shouldSatisfy(
124+
expectedMessageSubstringCaseInsensitive = "decoding data from the server's response failed",
125+
expectedCause = SerializationException::class,
126+
expectedRawData = mapOf("person_insert" to mapOf("id" to id)),
127+
expectedData = null,
128+
expectedErrors = emptyList(),
129+
)
130+
}
131+
132+
@Test
133+
fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest {
134+
val id = Arb.alphanumericString().next()
135+
val name = Arb.alphanumericString().next()
136+
personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute()
137+
val queryRef =
138+
dataConnect.query(
139+
operationName = "getPersonWithPartialFailure",
140+
variables = GetPersonWithPartialFailureVariables(id),
141+
dataDeserializer = serializer<GetPersonWithPartialFailureData>(),
142+
variablesSerializer = serializer(),
143+
optionsBuilder = {},
144+
)
145+
146+
val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }
147+
148+
exception.shouldSatisfy(
149+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
150+
expectedMessageSubstringCaseSensitive = "c8azjdwz2x",
151+
expectedCause = null,
152+
expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null),
153+
expectedData = GetPersonWithPartialFailureData(name),
154+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
155+
)
156+
}
157+
158+
@Test
159+
fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingSucceeds() = runTest {
160+
val id = Arb.alphanumericString().next()
161+
val name = Arb.alphanumericString().next()
162+
val mutationRef =
163+
dataConnect.mutation(
164+
operationName = "createPersonWithPartialFailure",
165+
variables = CreatePersonWithPartialFailureVariables(id = id, name = name),
166+
dataDeserializer = serializer<CreatePersonWithPartialFailureData>(),
167+
variablesSerializer = serializer(),
168+
optionsBuilder = {},
169+
)
170+
171+
val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }
172+
173+
exception.shouldSatisfy(
174+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
175+
expectedMessageSubstringCaseSensitive = "ecxpjy4qfy",
176+
expectedCause = null,
177+
expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null),
178+
expectedData = CreatePersonWithPartialFailureData(id),
179+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
180+
)
181+
}
182+
183+
@Test
184+
fun executeQueryFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest {
185+
val id = Arb.alphanumericString().next()
186+
val name = Arb.alphanumericString().next()
187+
personSchema.createPerson(CreatePersonMutation.Variables(id, name)).execute()
188+
val queryRef =
189+
dataConnect.query(
190+
operationName = "getPersonWithPartialFailure",
191+
variables = GetPersonWithPartialFailureVariables(id),
192+
dataDeserializer = serializer<IncompatibleData>(),
193+
variablesSerializer = serializer(),
194+
optionsBuilder = {},
195+
)
196+
197+
val exception = shouldThrow<DataConnectOperationException> { queryRef.execute() }
198+
199+
exception.shouldSatisfy(
200+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
201+
expectedMessageSubstringCaseSensitive = "c8azjdwz2x",
202+
expectedCause = null,
203+
expectedRawData = mapOf("person1" to mapOf("name" to name), "person2" to null),
204+
expectedData = null,
205+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
206+
)
207+
}
208+
209+
@Test
210+
fun executeMutationFailsWithNonNullDataNonEmptyErrorsDecodingFails() = runTest {
211+
val id = Arb.alphanumericString().next()
212+
val name = Arb.alphanumericString().next()
213+
val mutationRef =
214+
dataConnect.mutation(
215+
operationName = "createPersonWithPartialFailure",
216+
variables = CreatePersonWithPartialFailureVariables(id = id, name = name),
217+
dataDeserializer = serializer<IncompatibleData>(),
218+
variablesSerializer = serializer(),
219+
optionsBuilder = {},
220+
)
221+
222+
val exception = shouldThrow<DataConnectOperationException> { mutationRef.execute() }
223+
224+
exception.shouldSatisfy(
225+
expectedMessageSubstringCaseInsensitive = "operation encountered errors",
226+
expectedMessageSubstringCaseSensitive = "ecxpjy4qfy",
227+
expectedCause = null,
228+
expectedRawData = mapOf("person1" to mapOf("id" to id), "person2" to null),
229+
expectedData = null,
230+
errorsValidator = { it.shouldHaveAtLeastSize(1) },
231+
)
232+
}
233+
234+
@Serializable private data class IncompatibleVariables(val jwdbzka4k5: String)
235+
236+
@Serializable private data class IncompatibleData(val btzjhbfz7h: String)
237+
238+
private fun Arb.Companion.incompatibleVariables(string: Arb<String> = Arb.alphanumericString()) =
239+
string.map { IncompatibleVariables(it) }
240+
241+
@Serializable private data class GetPersonWithPartialFailureVariables(val id: String)
242+
243+
@Serializable
244+
private data class GetPersonWithPartialFailureData(val person1: Person, val person2: Nothing?) {
245+
constructor(person1Name: String) : this(Person(person1Name), null)
246+
247+
@Serializable private data class Person(val name: String)
248+
}
249+
250+
@Serializable
251+
private data class CreatePersonWithPartialFailureVariables(val id: String, val name: String)
252+
253+
@Serializable
254+
private data class CreatePersonWithPartialFailureData(
255+
val person1: Person,
256+
val person2: Nothing?
257+
) {
258+
constructor(person1Id: String) : this(Person(person1Id), null)
259+
260+
@Serializable private data class Person(val id: String)
261+
}
262+
}

firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/testutil/schemas/PersonSchema.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) {
5454
)
5555

5656
object CreatePersonMutation {
57+
const val operationName = "createPerson"
58+
5759
@Serializable
5860
data class Data(val person_insert: PersonKey) {
5961
@Serializable data class PersonKey(val id: String)
@@ -63,7 +65,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) {
6365

6466
fun createPerson(variables: CreatePersonMutation.Variables) =
6567
dataConnect.mutation(
66-
operationName = "createPerson",
68+
operationName = CreatePersonMutation.operationName,
6769
variables = variables,
6870
dataDeserializer = serializer<CreatePersonMutation.Data>(),
6971
variablesSerializer = serializer<CreatePersonMutation.Variables>(),
@@ -141,6 +143,8 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) {
141143
fun deletePerson(id: String) = deletePerson(DeletePersonMutation.Variables(id = id))
142144

143145
object GetPersonQuery {
146+
const val operationName = "getPerson"
147+
144148
@Serializable
145149
data class Data(val person: Person?) {
146150
@Serializable data class Person(val name: String, val age: Int? = null)
@@ -151,7 +155,7 @@ class PersonSchema(val dataConnect: FirebaseDataConnect) {
151155

152156
fun getPerson(variables: GetPersonQuery.Variables) =
153157
dataConnect.query(
154-
operationName = "getPerson",
158+
operationName = GetPersonQuery.operationName,
155159
variables = variables,
156160
dataDeserializer = serializer<GetPersonQuery.Data>(),
157161
variablesSerializer = serializer<GetPersonQuery.Variables>(),

0 commit comments

Comments
 (0)