diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index d7d9d52b70a..b9c19ffdafc 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -1,4 +1,7 @@ # Unreleased +- [changed] Ignore unknown fields in response data instead of throwing a + `DataConnectOperationException` with message "decoding data from the server's response failed: + An unknown field for index -3" # 17.0.0 diff --git a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql index db53ca04dd2..8699704aee9 100644 --- a/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql +++ b/firebase-dataconnect/emulator/dataconnect/connector/person/person_ops.gql @@ -105,3 +105,10 @@ mutation createPersonWithPartialFailureInTransaction($id: String!, $name: String person2: person_insert(data: { id_expr: "uuidV4()", name: $name }) @check(expr: "false", message: "te36b3zkvn") } +mutation create5People @auth(level: PUBLIC) { + person1: person_upsert(data: { id: "8a218894521f457a9be45a0986494058", name: "Name1" }) + person2: person_upsert(data: { id: "464443371f284194be4b2e78c3ef000c", name: "Name2" }) + person3: person_upsert(data: { id: "903d83db81754bd29860458f127ef124", name: "Name3" }) + person4: person_upsert(data: { id: "ef8de2a4a6de400e94d555f148b643c0", name: "Name4" }) + person5: person_upsert(data: { id: "8584fd7ca2b6453da18d21d4341f1804", name: "Name5" }) +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/UnknownKeysIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/UnknownKeysIntegrationTest.kt new file mode 100644 index 00000000000..b24021dfb42 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/UnknownKeysIntegrationTest.kt @@ -0,0 +1,140 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalFirebaseDataConnect::class) + +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.schemas.AllTypesSchema +import com.google.firebase.dataconnect.testutil.schemas.PersonSchema +import io.kotest.assertions.withClue +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.arbitrary.next +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import org.junit.Test + +class UnknownKeysIntegrationTest : DataConnectIntegrationTestBase() { + + private val personSchema by lazy { PersonSchema(dataConnectFactory) } + private val allTypesSchema by lazy { AllTypesSchema(dataConnectFactory) } + + @Test + fun unknownKeysInQueryResponseDataShouldBeIgnored() = runTest { + val id = Arb.dataConnect.uuid().next() + allTypesSchema + .createPrimitive( + AllTypesSchema.PrimitiveData( + id = id, + idFieldNullable = "0d2fcdf1c4a84c64a87f3c7932b31749", + intField = 42, + intFieldNullable = 43, + floatField = 123.45, + floatFieldNullable = 678.91, + booleanField = true, + booleanFieldNullable = false, + stringField = "TestString", + stringFieldNullable = "TestNullableString" + ) + ) + .execute() + + @Serializable + data class PrimitiveQueryDataValues( + val intFieldNullable: Int?, + val booleanField: Boolean, + val booleanFieldNullable: Boolean?, + val stringField: String, + val stringFieldNullable: String?, + ) + + /** An adaptation of [AllTypesSchema.GetPrimitiveQuery.Data] with some keys missing. */ + @Serializable + data class PrimitiveQueryDataMissingSomeKeys(val primitive: PrimitiveQueryDataValues) + + val result = + allTypesSchema + .getPrimitive(id = id) + .withDataDeserializer(serializer()) + .execute() + + result.data.primitive shouldBe + PrimitiveQueryDataValues( + intFieldNullable = 43, + booleanField = true, + booleanFieldNullable = false, + stringField = "TestString", + stringFieldNullable = "TestNullableString" + ) + } + + @Test + fun unknownKeysInMutationResponseDataShouldBeIgnored() = runTest { + @Serializable data class PersonKeyWithId(val id: String) + val person1 = PersonKeyWithId(id = "8a218894521f457a9be45a0986494058") + val person4 = PersonKeyWithId(id = "ef8de2a4a6de400e94d555f148b643c0") + val mutationRef = + personSchema.dataConnect.mutation("create5People", Unit, serializer(), serializer()) + + // Precondition check: Verify that the response contains person1..person5 and the expected IDs. + withClue("precondition check") { + @Serializable + data class Create5PeopleData( + val person1: PersonKeyWithId, + val person3: PersonKeyWithId, + val person2: PersonKeyWithId, + val person4: PersonKeyWithId, + val person5: PersonKeyWithId, + ) + + val mutationResult = + mutationRef.withDataDeserializer(serializer()).execute() + mutationResult.data shouldBe + Create5PeopleData( + person1 = person1, + person2 = PersonKeyWithId(id = "464443371f284194be4b2e78c3ef000c"), + person3 = PersonKeyWithId(id = "903d83db81754bd29860458f127ef124"), + person4 = person4, + person5 = PersonKeyWithId(id = "8584fd7ca2b6453da18d21d4341f1804"), + ) + } + + withClue("actual test") { + // Create5PeopleDataWithMissingKeys is missing "person2.id", "person3", and "person5" which + // will be present in the response. + @Serializable data class PersonKeyWithoutId(val foo: Nothing? = null) + @Serializable + data class Create5PeopleDataWithMissingKeys( + val person1: PersonKeyWithId, + val person2: PersonKeyWithoutId, + val person4: PersonKeyWithId, + ) + + val mutationResult = + mutationRef.withDataDeserializer(serializer()).execute() + mutationResult.data shouldBe + Create5PeopleDataWithMissingKeys( + person1 = person1, + person2 = PersonKeyWithoutId(), + person4 = PersonKeyWithId(id = "ef8de2a4a6de400e94d555f148b643c0"), + ) + } + } +} 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 10af8fc2b53..a90ce4c3847 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 @@ -177,7 +177,12 @@ private class ProtoStructValueDecoder( addAll(struct.fieldsMap.keys) addAll(descriptor.elementNames) } - elementIndexes = names.map(descriptor::getElementIndex).sorted().iterator() + elementIndexes = + names + .map(descriptor::getElementIndex) + .filter { it != CompositeDecoder.UNKNOWN_NAME } // ignore unknown keys + .sorted() + .iterator() } return elementIndexes 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 index 614687637dc..0eec93d706e 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/ProtoStructDecoderUnitTest.kt @@ -19,6 +19,8 @@ package com.google.firebase.dataconnect import com.google.firebase.dataconnect.SerializationTestData.serializationTestDataAllTypes import com.google.firebase.dataconnect.SerializationTestData.withEmptyListOfUnitRecursive +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.twoValues import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingTextIgnoringCase import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromStruct @@ -477,6 +479,21 @@ class ProtoStructDecoderUnitTest { } } + @Test + fun `decodeFromStruct() ignores unknown struct keys`() = runTest { + @Serializable data class TestData1(val value1: String, val value2: String) + @Serializable data class TestData2(val value1: String) + + val testData1Arb: Arb = + Arb.twoValues(Arb.dataConnect.string()).map { (value1, value2) -> TestData1(value1, value2) } + + checkAll(propTestConfig, testData1Arb) { testData1 -> + val struct = encodeToStruct(testData1) + val decodedTestData = decodeFromStruct(struct) + decodedTestData shouldBe TestData2(testData1.value1) + } + } + @Test fun `decodeFromStruct() should throw SerializationException if attempting to decode an Int`() { assertDecodeFromStructThrowsIncorrectKindCase(