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 index 58869bebdb8..6e7f11c22b7 100644 --- 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 @@ -35,10 +35,8 @@ import com.google.firebase.dataconnect.util.CoroutineUtils import com.google.firebase.dataconnect.util.GrpcBidiFlow import com.google.firebase.dataconnect.util.GrpcBidiFlowListenerMessageFormatter import com.google.firebase.dataconnect.util.IdStringGenerator -import com.google.firebase.dataconnect.util.ImmutableByteArray import com.google.firebase.dataconnect.util.NullableReference import com.google.firebase.dataconnect.util.ProtoUtil.buildStructProto -import com.google.firebase.dataconnect.util.ProtoUtil.calculateSha512 import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString import com.google.firebase.dataconnect.util.ProtoUtil.toDataConnectPath import com.google.firebase.dataconnect.util.ProtoUtil.toStructProto @@ -94,7 +92,7 @@ internal class DataConnectGrpcRPCs( host: String, sslEnabled: Boolean, @get:VisibleForTesting val connectorResourceName: String, - nonBlockingCoroutineDispatcher: CoroutineDispatcher, + private val nonBlockingCoroutineDispatcher: CoroutineDispatcher, private val blockingCoroutineDispatcher: CoroutineDispatcher, private val grpcMetadata: DataConnectGrpcMetadata, private val cacheSettings: CacheSettings?, @@ -242,22 +240,27 @@ internal class DataConnectGrpcRPCs( private class QueryCacheInfo( val cacheDb: DataConnectCacheDatabase, val authUid: AuthUid?, - val queryId: ImmutableByteArray, + val queryId: QueryId, val maxAge: DurationProto, ) private suspend fun queryCacheInfo( authToken: DataConnectAuth.GetAuthTokenResult?, request: ExecuteQueryRequest, - ) = - lazyCacheDb.get().ref?.let { (cacheDb, maxAge) -> + ): QueryCacheInfo? { + val queryId = + withContext(nonBlockingCoroutineDispatcher) { + calculateQueryId(request.operationName, request.variables) + } + return lazyCacheDb.get().ref?.let { (cacheDb, maxAge) -> QueryCacheInfo( cacheDb, authUid = authToken?.authUid, - queryId = request.calculateQueryId(), + queryId = queryId, maxAge = maxAge, ) } + } suspend fun executeQuery( requestId: String, @@ -784,9 +787,6 @@ internal class DataConnectGrpcRPCs( } } -private fun ExecuteQueryRequest.calculateQueryId(): ImmutableByteArray = - variables.calculateSha512(preamble = operationName) - @JvmName("getEntityIdForPathFunction_ExecuteQueryResponse") private fun ExecuteQueryResponse.getEntityIdForPathFunction(): GetEntityIdForPathFunction? = if (extensions.dataConnectCount == 0) { diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryId.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryId.kt new file mode 100644 index 00000000000..55f66ae1b11 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/core/QueryId.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.annotation.WorkerThread +import com.google.firebase.dataconnect.util.ImmutableByteArray +import com.google.firebase.dataconnect.util.ProtoUtil.calculateSha512 +import com.google.protobuf.Struct + +/** + * Represents bytes that comprise a stable ID for a Data Connect query. + * + * These query IDs are "stable" in the sense that a given operation name/variables pair will + * _always_ generate the same ID. Also, the probability of two queries that differ, even by one + * byte, in their operation name or variables, have an effectively zero chance of having the same + * query ID. Finally, the ID of a given operation name/variables pair will be the same across + * application restarts and device resets. + * + * These properties make Query IDs represented by this type suitable for storing in persistence as a + * key for data related to a query, such as cached query results. + * + * Use [calculateQueryId] to calculate the Query ID for a query. + */ +@JvmInline +internal value class QueryId(val bytes: ImmutableByteArray) { + override fun toString() = "QueryId(${bytes.to0xHexString()})" +} + +/** + * Calculates the [QueryId] for a Data Connect query with the given operation name and variables. + * + * This computation is CPU intensive and, therefore, should _never_ be called on the main thread. + */ +@WorkerThread +internal fun calculateQueryId(operationName: String, variables: Struct): QueryId = + QueryId(variables.calculateSha512(preamble = operationName)) diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabase.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabase.kt index 35c316b4961..740de0dc8b9 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabase.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabase.kt @@ -21,6 +21,7 @@ import android.database.sqlite.SQLiteDatabase import com.google.firebase.dataconnect.core.DataConnectAuth.AuthUid import com.google.firebase.dataconnect.core.Logger import com.google.firebase.dataconnect.core.LoggerGlobals.warn +import com.google.firebase.dataconnect.core.QueryId import com.google.firebase.dataconnect.sqlite.DataConnectCacheDatabase.GetQueryResultResult import com.google.firebase.dataconnect.sqlite.SQLiteDatabaseExts.execSQL import com.google.firebase.dataconnect.sqlite.SQLiteDatabaseExts.getLastInsertRowId @@ -222,14 +223,11 @@ internal class DataConnectCacheDatabase( val expiryProto: QueryResultExpiry, ) - private fun SQLiteDatabase.getQuery( - user: SqliteUserId, - queryId: ImmutableByteArray - ): GetQueryResult? = + private fun SQLiteDatabase.getQuery(user: SqliteUserId, queryId: QueryId): GetQueryResult? = rawQuery( logger, "SELECT id, data, expiry, flags FROM queries WHERE user_id=? AND query_id=?", - bindArgs = arrayOf(user.sqliteRowId, queryId.peek()), + bindArgs = arrayOf(user.sqliteRowId, queryId.bytes.peek()), ) { cursor -> if (!cursor.moveToNext()) { null @@ -255,7 +253,7 @@ internal class DataConnectCacheDatabase( parseResult.onFailure { logger.warn(it) { "Parsing QueryResultProto failed for id=$id, user=$user, " + - "queryId=${queryId.to0xHexString()}, flags=$flags [ykb2vwrcge]" + "queryId=$queryId, flags=$flags [ykb2vwrcge]" } } @@ -264,7 +262,7 @@ internal class DataConnectCacheDatabase( expiryParseResult.onFailure { logger.warn(it) { "Parsing QueryResultExpiry failed for id=$id, user=$user, " + - "queryId=${queryId.to0xHexString()}, flags=$flags [x9k2c3b8y1]" + "queryId=$queryId, flags=$flags [x9k2c3b8y1]" } } @@ -274,7 +272,7 @@ internal class DataConnectCacheDatabase( private fun SQLiteDatabase.insertQuery( user: SqliteUserId, - queryId: ImmutableByteArray, + queryId: QueryId, queryResultProtoBytes: ImmutableByteArray, expiryProtoBytes: ImmutableByteArray, ): SqliteQueryId { @@ -287,7 +285,7 @@ internal class DataConnectCacheDatabase( """, arrayOf( user.sqliteRowId, - queryId.peek(), + queryId.bytes.peek(), queryResultProtoBytes.peek(), expiryProtoBytes.peek() ) @@ -453,7 +451,7 @@ internal class DataConnectCacheDatabase( suspend fun getQueryResult( authUid: AuthUid?, - queryId: ImmutableByteArray, + queryId: QueryId, currentTimeMillis: Long, staleResult: KClass, ): GetQueryResultResult { @@ -509,7 +507,7 @@ internal class DataConnectCacheDatabase( rehydrateResult.onFailure { logger.warn { "rehydrateQueryResult failed for id=${sqliteQueryId.sqliteRowId}, " + - "queryId=${queryId.to0xHexString()} [knpe3t4f5b]" + "queryId=$queryId [knpe3t4f5b]" } } @@ -526,14 +524,14 @@ internal class DataConnectCacheDatabase( suspend fun insertQueryResult( authUid: AuthUid?, - queryId: ImmutableByteArray, + queryId: QueryId, queryData: Struct, maxAge: DurationProto, currentTimeMillis: Long, getEntityIdForPath: GetEntityIdForPathFunction?, ) { - require(queryId.size > 0) { - "queryId.size=${queryId.size}, but must be greater than zero [ab4em538tb]" + require(queryId.bytes.size > 0) { + "queryId.bytes.size=${queryId.bytes.size}, but must be greater than zero [ab4em538tb]" } val (queryResultProto, entityStructById) = dehydrateQueryResult(queryData, getEntityIdForPath) val queryResultProtoBytes = ImmutableByteArray.adopt(queryResultProto.toByteArray()) diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryIdUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryIdUnitTest.kt new file mode 100644 index 00000000000..d915900ea5b --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/core/QueryIdUnitTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.deserializeStructVerbatim +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import com.google.firebase.dataconnect.testutil.serializeStructVerbatim +import com.google.firebase.dataconnect.util.ImmutableByteArray +import com.google.firebase.dataconnect.util.NullOutputStream +import com.google.protobuf.Struct +import com.google.protobuf.Value +import com.google.protobuf.Value.KindCase +import io.kotest.common.ExperimentalKotest +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.ShrinkingMode +import io.kotest.property.checkAll +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.security.DigestOutputStream +import java.security.MessageDigest +import java.util.zip.GZIPInputStream +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class QueryIdUnitTest { + + @Test + fun `calculateQueryId() returns the expected value`() = runTest { + val stringArb = Arb.dataConnect.string() + checkAll(propTestConfig, stringArb, Arb.proto.struct(key = stringArb)) { + operationName, + variables -> + val actual = calculateQueryId(operationName, variables.struct) + val expected = calculateExpectedSha512(operationName, variables.struct) + actual.bytes.peek() shouldBe expected + } + } + + @Test + fun `calculateQueryId() with precomputed expected values`() = runTest { + val testCases = loadTestCases(this::class.java.classLoader!!) + testCases.forEach { (operationName, variables, queryId) -> + val actual = calculateQueryId(operationName, variables) + actual shouldBe queryId + } + } +} + +@OptIn(ExperimentalKotest::class) +private val propTestConfig = + PropTestConfig( + iterations = 200, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.2), + shrinkingMode = ShrinkingMode.Off, + ) + +/** + * Calculates the Sha512 hash used as the "Query ID" for a query with the given operation name and + * variables. + * + * This method is essentially a copy of the original algorithm used to calculate Query IDs. Since + * Query IDs are persisted into the database the algorithm MUST NEVER change. By copying the + * algorithm here into the test we can assure that any refactorings of the algorithm continue to + * produce the same values. + */ +private fun calculateExpectedSha512(operationName: String, variables: Struct): ByteArray { + val digest = MessageDigest.getInstance("SHA-512") + val out = DataOutputStream(DigestOutputStream(NullOutputStream, digest)) + out.writeUTF(operationName) + calculateExpectedSha512(Value.newBuilder().setStructValue(variables).build(), out) + return digest.digest() +} + +/** Helper method for [calculateExpectedSha512] that performs the recursive SHA512 calculation. */ +private fun calculateExpectedSha512(value: Value, out: DataOutputStream) { + val kind = value.kindCase + out.writeInt(kind.ordinal) + + when (kind) { + KindCase.NULL_VALUE, + KindCase.KIND_NOT_SET -> { + /* nothing to write for null or kind-not-set */ + } + KindCase.BOOL_VALUE -> out.writeBoolean(value.boolValue) + KindCase.NUMBER_VALUE -> out.writeDouble(value.numberValue) + KindCase.STRING_VALUE -> out.writeUTF(value.stringValue) + KindCase.LIST_VALUE -> + value.listValue.valuesList.forEachIndexed { index, elementValue -> + out.writeInt(index) + calculateExpectedSha512(elementValue, out) + } + KindCase.STRUCT_VALUE -> + value.structValue.fieldsMap.entries + .sortedBy { (key, _) -> key } + .forEach { (key, elementValue) -> + out.writeUTF(key) + calculateExpectedSha512(elementValue, out) + } + } + + out.writeInt(kind.ordinal) +} + +/** Stores a "test case"; that is, an operation name and variables and the expected Query ID. */ +private data class TestCase( + val operationName: String, + val variables: Struct, + val queryId: QueryId +) { + + @Suppress("unused") + fun encode(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + DataOutputStream(byteArrayOutputStream).use { dataOutputStream -> encodeTo(dataOutputStream) } + return byteArrayOutputStream.toByteArray() + } + + private fun encodeTo(out: DataOutputStream) { + out.writeInt(operationName.length) + out.writeChars(operationName) + val variablesBytes = serializeStructVerbatim(variables) + out.writeInt(variablesBytes.size) + out.write(variablesBytes) + out.writeInt(queryId.bytes.size) + out.write(queryId.bytes.peek()) + } + + companion object { + + fun decode(bytes: ByteArray): TestCase { + val dataInputStream = DataInputStream(ByteArrayInputStream(bytes)) + return decodeFrom(dataInputStream) + } + + private fun decodeFrom(stream: DataInputStream): TestCase { + val operationName = run { + val charCount = stream.readInt() + stream.readChars(charCount) + } + val variables = run { + val byteCount = stream.readInt() + val bytes = ByteArray(byteCount) + stream.readFully(bytes) + deserializeStructVerbatim(bytes) + } + val queryId = run { + val byteCount = stream.readInt() + val bytes = ByteArray(byteCount) + stream.readFully(bytes) + QueryId(ImmutableByteArray.adopt(bytes)) + } + return TestCase(operationName, variables, queryId) + } + + private fun DataInputStream.readChars(charCount: Int): String = buildString { + repeat(charCount) { append(readChar()) } + } + } +} + +/** Loads the persisted [TestCase] objects. */ +private fun loadTestCases(classLoader: ClassLoader): List { + classLoader + .getResourceAsStream("com/google/firebase/dataconnect/core/QueryIdUnitTestTestCases.dat.gz") + .use { resourceStream -> + val gzipInputStream = GZIPInputStream(resourceStream) + val dataInputStream = DataInputStream(gzipInputStream) + val testCaseCount = dataInputStream.readInt() + return List(testCaseCount) { + val testCaseByteCount = dataInputStream.readInt() + val testCaseBytes = ByteArray(testCaseByteCount) + dataInputStream.readFully(testCaseBytes) + TestCase.decode(testCaseBytes) + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabaseUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabaseUnitTest.kt index e768de4094e..a7fa313555d 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabaseUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/sqlite/DataConnectCacheDatabaseUnitTest.kt @@ -19,6 +19,7 @@ package com.google.firebase.dataconnect.sqlite import android.database.sqlite.SQLiteDatabase import com.google.firebase.dataconnect.core.DataConnectAuth.AuthUid import com.google.firebase.dataconnect.core.Logger +import com.google.firebase.dataconnect.core.QueryId import com.google.firebase.dataconnect.sqlite.DataConnectCacheDatabase.GetQueryResultResult import com.google.firebase.dataconnect.sqlite.DataConnectCacheDatabase.GetQueryResultResult.Found import com.google.firebase.dataconnect.sqlite.DataConnectCacheDatabase.GetQueryResultResult.NotFound @@ -278,7 +279,7 @@ class DataConnectCacheDatabaseUnitTest { ) { authUid, queryId, maxAge, structSample -> dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, structSample.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -288,7 +289,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -311,7 +312,7 @@ class DataConnectCacheDatabaseUnitTest { structSamples.forEach { dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, it.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -322,7 +323,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -344,7 +345,7 @@ class DataConnectCacheDatabaseUnitTest { ) { (authUid1, authUid2), queryId, maxAge, (structSample1, structSample2) -> dataConnectCacheDatabase.insertQueryResult( authUid1.authUid, - queryId.bytes, + queryId.queryId, structSample1.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -352,7 +353,7 @@ class DataConnectCacheDatabaseUnitTest { ) dataConnectCacheDatabase.insertQueryResult( authUid2.authUid, - queryId.bytes, + queryId.queryId, structSample2.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -362,7 +363,7 @@ class DataConnectCacheDatabaseUnitTest { val result1 = dataConnectCacheDatabase.getQueryResult( authUid1.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -370,7 +371,7 @@ class DataConnectCacheDatabaseUnitTest { val result2 = dataConnectCacheDatabase.getQueryResult( authUid2.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -395,7 +396,7 @@ class DataConnectCacheDatabaseUnitTest { ) { authUid, (queryId1, queryId2), maxAge, (structSample1, structSample2) -> dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId1.bytes, + queryId1.queryId, structSample1.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -403,7 +404,7 @@ class DataConnectCacheDatabaseUnitTest { ) dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId2.bytes, + queryId2.queryId, structSample2.struct, maxAge = maxAge, currentTimeMillis = 0L, @@ -413,7 +414,7 @@ class DataConnectCacheDatabaseUnitTest { val result1 = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId1.bytes, + queryId1.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -421,7 +422,7 @@ class DataConnectCacheDatabaseUnitTest { val result2 = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId2.bytes, + queryId2.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -446,7 +447,7 @@ class DataConnectCacheDatabaseUnitTest { ) { authUid, queryId, maxAge, queryResult -> dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, queryResult.hydratedStruct, maxAge = maxAge, currentTimeMillis = 0L, @@ -456,7 +457,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -499,7 +500,7 @@ class DataConnectCacheDatabaseUnitTest { queryIds.zip(queryResults).forEach { (queryId, queryResult) -> dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, queryResult.hydratedStruct, maxAge = maxAge, currentTimeMillis = 0L, @@ -514,14 +515,13 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) val structFromDb = result.shouldBeInstanceOf().struct withClue( - "queryIdIndex=$queryIdIndex size=${queryIds.size}, " + - "queryId=${queryId.bytes.to0xHexString()}" + "queryIdIndex=$queryIdIndex size=${queryIds.size}, " + "queryId=${queryId.queryId}" ) { structFromDb shouldBe queryResult.hydratedStruct } @@ -550,7 +550,7 @@ class DataConnectCacheDatabaseUnitTest { queryIds.zip(queryResults).forEach { (queryId, queryResult) -> dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, queryResult.hydratedStruct, maxAge = maxAge, currentTimeMillis = 0L, @@ -562,11 +562,11 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) - withClue("index=$index size=${queryIds.size}, queryId=${queryId.bytes.to0xHexString()}") { + withClue("index=$index size=${queryIds.size}, queryId=${queryId.queryId}") { val structFromDb = result.shouldBeInstanceOf().struct structFromDb shouldBe queryResult.hydratedStruct } @@ -594,7 +594,7 @@ class DataConnectCacheDatabaseUnitTest { dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId1.bytes, + queryId1.queryId, queryResult1.hydratedStruct, maxAge = maxAge, currentTimeMillis = 0L, @@ -602,7 +602,7 @@ class DataConnectCacheDatabaseUnitTest { ) dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId2.bytes, + queryId2.queryId, queryResult2.hydratedStruct, maxAge = maxAge, currentTimeMillis = 0L, @@ -613,7 +613,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId2.bytes, + queryId2.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -624,7 +624,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId1.bytes, + queryId1.queryId, currentTimeMillis = 0L, staleResult = Stale::class, ) @@ -860,7 +860,7 @@ class DataConnectCacheDatabaseUnitTest { ) { dataConnectCacheDatabase.insertQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, queryResultData, maxAge = maxAge, currentTimeMillis = time1, @@ -870,7 +870,7 @@ class DataConnectCacheDatabaseUnitTest { val result = dataConnectCacheDatabase.getQueryResult( authUid.authUid, - queryId.bytes, + queryId.queryId, currentTimeMillis = time2, staleResult, ) @@ -894,12 +894,14 @@ private fun authUidArb(): Arb = AuthUidSample(if (it !== null) AuthUid(it) else null) } -private data class QueryIdSample(val bytes: ImmutableByteArray) { - override fun toString() = "QueryIdSample(bytes=${bytes.to0xHexString()})" +private data class QueryIdSample(val queryId: QueryId) { + override fun toString() = "QueryIdSample(queryId=$queryId)" } private fun queryIdArb(): Arb = - Arb.byteArray(Arb.int(1..25), Arb.byte()).map { QueryIdSample(ImmutableByteArray.adopt(it)) } + Arb.byteArray(Arb.int(1..25), Arb.byte()).map { + QueryIdSample(QueryId(ImmutableByteArray.adopt(it))) + } internal fun QueryResultArb.Sample.hydratedStructWithMutatedEntityValuesFrom( other: QueryResultArb.Sample diff --git a/firebase-dataconnect/src/test/resources/com/google/firebase/dataconnect/core/QueryIdUnitTestTestCases.dat.gz b/firebase-dataconnect/src/test/resources/com/google/firebase/dataconnect/core/QueryIdUnitTestTestCases.dat.gz new file mode 100644 index 00000000000..cafdee5fa20 Binary files /dev/null and b/firebase-dataconnect/src/test/resources/com/google/firebase/dataconnect/core/QueryIdUnitTestTestCases.dat.gz differ diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerialization.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerialization.kt new file mode 100644 index 00000000000..c68e0407d8e --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerialization.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.protobuf.ListValue +import com.google.protobuf.NullValue +import com.google.protobuf.Struct +import com.google.protobuf.Value +import com.google.protobuf.Value.KindCase +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream + +/** + * Serializes a [Struct] into a [ByteArray] such that [deserializeStructVerbatim] can recreate the + * original [Struct] verbatim without any artifacts of UTF-8 encoding or normal proto encoding. + */ +fun serializeStructVerbatim(struct: Struct): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + DataOutputStream(byteArrayOutputStream).use { dataOutputStream -> + dataOutputStream.writeStruct(struct) + } + return byteArrayOutputStream.toByteArray() +} + +/** Deserializes a [Struct] from a [ByteArray] created by [serializeStructVerbatim]. */ +fun deserializeStructVerbatim(byteArray: ByteArray): Struct { + val dataInputStream = DataInputStream(ByteArrayInputStream(byteArray)) + return dataInputStream.readStruct() +} + +private fun DataOutputStream.writeStruct(struct: Struct) { + writeInt(struct.fieldsCount) + struct.fieldsMap.keys.forEach { key -> + writeStructKey(key) + writeValue(struct.getFieldsOrThrow(key)) + } +} + +private fun DataInputStream.readStruct(): Struct { + val fieldCount = readInt() + val structBuilder = Struct.newBuilder() + repeat(fieldCount) { + val key = readStructKey() + val value = readValue() + structBuilder.putFields(key, value) + } + return structBuilder.build() +} + +private fun DataOutputStream.writeStructKey(key: String) { + writeInt(key.length) + writeChars(key) +} + +private fun DataInputStream.readStructKey(): String { + val length = readInt() + return buildString(length) { repeat(length) { append(readChar()) } } +} + +private fun DataOutputStream.writeListValue(listValue: ListValue) { + writeInt(listValue.valuesCount) + listValue.valuesList.forEach { value -> writeValue(value) } +} + +private fun DataInputStream.readListValue(): ListValue { + val valueCount = readInt() + val listValueBuilder = ListValue.newBuilder() + repeat(valueCount) { + val value = readValue() + listValueBuilder.addValues(value) + } + return listValueBuilder.build() +} + +private fun DataOutputStream.writeKindCase(kindCase: KindCase) { + writeInt( + when (kindCase) { + KindCase.NULL_VALUE -> 0 + KindCase.NUMBER_VALUE -> 1 + KindCase.STRING_VALUE -> 2 + KindCase.BOOL_VALUE -> 3 + KindCase.STRUCT_VALUE -> 4 + KindCase.LIST_VALUE -> 5 + KindCase.KIND_NOT_SET -> 6 + } + ) +} + +private fun DataInputStream.readKindCase(): KindCase = + when (val int = readInt()) { + 0 -> KindCase.NULL_VALUE + 1 -> KindCase.NUMBER_VALUE + 2 -> KindCase.STRING_VALUE + 3 -> KindCase.BOOL_VALUE + 4 -> KindCase.STRUCT_VALUE + 5 -> KindCase.LIST_VALUE + 6 -> KindCase.KIND_NOT_SET + else -> error("invalid KindCase int: $int [hxnjyc752z]") + } + +private fun DataOutputStream.writeValue(value: Value) { + writeKindCase(value.kindCase) + when (value.kindCase) { + KindCase.KIND_NOT_SET, + KindCase.NULL_VALUE -> {} + KindCase.NUMBER_VALUE -> writeDouble(value.numberValue) + KindCase.STRING_VALUE -> writeStructKey(value.stringValue) + KindCase.BOOL_VALUE -> writeBoolean(value.boolValue) + KindCase.STRUCT_VALUE -> writeStruct(value.structValue) + KindCase.LIST_VALUE -> writeListValue(value.listValue) + } +} + +private fun DataInputStream.readValue(): Value { + val valueBuilder = Value.newBuilder() + when (readKindCase()) { + KindCase.KIND_NOT_SET -> {} + KindCase.NULL_VALUE -> valueBuilder.setNullValue(NullValue.NULL_VALUE) + KindCase.NUMBER_VALUE -> valueBuilder.setNumberValue(readDouble()) + KindCase.STRING_VALUE -> valueBuilder.setStringValue(readStructKey()) + KindCase.BOOL_VALUE -> valueBuilder.setBoolValue(readBoolean()) + KindCase.STRUCT_VALUE -> valueBuilder.setStructValue(readStruct()) + KindCase.LIST_VALUE -> valueBuilder.setListValue(readListValue()) + } + return valueBuilder.build() +} diff --git a/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerializationUnitTest.kt b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerializationUnitTest.kt new file mode 100644 index 00000000000..2b4d92626fe --- /dev/null +++ b/firebase-dataconnect/testutil/src/test/kotlin/com/google/firebase/dataconnect/testutil/ProtoValueSerializationUnitTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.testutil.property.arbitrary.proto +import com.google.firebase.dataconnect.testutil.property.arbitrary.struct +import io.kotest.common.ExperimentalKotest +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.ShrinkingMode +import io.kotest.property.checkAll +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ProtoValueSerializationUnitTest { + + @Test + fun encodeDecodeRoundTrip() = runTest { + checkAll(propTestConfig, Arb.proto.struct()) { + val byteArray = serializeStructVerbatim(it.struct) + val deserializedStruct = deserializeStructVerbatim(byteArray) + deserializedStruct shouldBe it.struct + } + } +} + +@OptIn(ExperimentalKotest::class) +private val propTestConfig = + PropTestConfig( + iterations = 1000, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.2), + shrinkingMode = ShrinkingMode.Off, + )