diff --git a/firebase-dataconnect/CHANGELOG.md b/firebase-dataconnect/CHANGELOG.md index 05d84807aad..a5502044e1d 100644 --- a/firebase-dataconnect/CHANGELOG.md +++ b/firebase-dataconnect/CHANGELOG.md @@ -30,6 +30,12 @@ paired with code generated by version 1.7.0 (or later) of the Data Connect emulator. ([#6513](https://github.com/firebase/firebase-android-sdk/pull/6513)) +* [feature] JavaTimeLocalDateSerializer and KotlinxDatetimeLocalDateSerializer + added, to enable using the standard "local date" classes `java.time.LocalDate` + and/or `kotlinx.datetime.LocalDate` instead of the bespoke + `com.google.firebase.dataconnect.LocalDate` class for `Date` GraphQL fields + and variables. + ([#6519](https://github.com/firebase/firebase-android-sdk/pull/6519)) # 16.0.0-beta02 * [changed] Updated protobuf dependency to `3.25.5` to fix diff --git a/firebase-dataconnect/api.txt b/firebase-dataconnect/api.txt index 11e44aa0dae..f24ccc3c298 100644 --- a/firebase-dataconnect/api.txt +++ b/firebase-dataconnect/api.txt @@ -310,6 +310,22 @@ package com.google.firebase.dataconnect.serializers { field @NonNull public static final com.google.firebase.dataconnect.serializers.AnyValueSerializer INSTANCE; } + public final class JavaTimeLocalDateSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public java.time.LocalDate 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.time.LocalDate value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.JavaTimeLocalDateSerializer INSTANCE; + } + + public final class KotlinxDatetimeLocalDateSerializer implements kotlinx.serialization.KSerializer { + method @NonNull public kotlinx.datetime.LocalDate 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 kotlinx.datetime.LocalDate value); + property @NonNull public kotlinx.serialization.descriptors.SerialDescriptor descriptor; + field @NonNull public static final com.google.firebase.dataconnect.serializers.KotlinxDatetimeLocalDateSerializer INSTANCE; + } + public final class LocalDateSerializer implements kotlinx.serialization.KSerializer { method @NonNull public com.google.firebase.dataconnect.LocalDate deserialize(@NonNull kotlinx.serialization.encoding.Decoder decoder); method @NonNull public kotlinx.serialization.descriptors.SerialDescriptor getDescriptor(); diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/JavaTimeLocalDateIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/JavaTimeLocalDateIntegrationTest.kt new file mode 100644 index 00000000000..e99238cf00e --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/JavaTimeLocalDateIntegrationTest.kt @@ -0,0 +1,720 @@ +/* + * 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, JavaTimeLocalDateSerializer::class) + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateIntegrationTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO LocalDateIntegrationTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.serializers.JavaTimeLocalDateSerializer +import com.google.firebase.dataconnect.serializers.UUIDSerializer +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.property.arbitrary.DateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.EdgeCases +import com.google.firebase.dataconnect.testutil.property.arbitrary.ThreeDateTestDatas +import com.google.firebase.dataconnect.testutil.property.arbitrary.ThreeDateTestDatas.ItemNumber +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.dateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.invalidDateScalarString +import com.google.firebase.dataconnect.testutil.property.arbitrary.localDate +import com.google.firebase.dataconnect.testutil.property.arbitrary.orNullableReference +import com.google.firebase.dataconnect.testutil.property.arbitrary.threeNonNullDatesTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.threePossiblyNullDatesTestData +import com.google.firebase.dataconnect.testutil.requestTimeAsDate +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingTextIgnoringCase +import com.google.firebase.dataconnect.testutil.toTheeTenAbpJavaLocalDate +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.shouldBeIn +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.withEdgecases +import io.kotest.property.checkAll +import java.util.UUID +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.serializer +import org.junit.Test + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateIntegrationTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO LocalDateIntegrationTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +class JavaTimeLocalDateIntegrationTest : 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 DateNonNullable @table { value: Date!, tag: String } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig, Arb.dataConnect.localDate().map { it.toJavaLocalDate() }) { localDate + -> + val insertResult = nonNullableDate.insert(localDate) + val queryResult = nonNullableDate.getByKey(insertResult.data.key) + queryResult.data.item?.value shouldBe localDate + } + } + + @Test + fun dateNonNullable_QueryVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threeNonNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = + nonNullableDate.getAllByTagAndValue(tag, testDatas.selected!!.date.toJavaLocalDate()) + val matchingIds = testDatas.idsMatchingSelected(insertResult) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNonNullable_MutationNullVariableShouldThrow() = runTest { + val exception = shouldThrow { nonNullableDate.insert(null) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingText "is null" + } + } + + @Test + fun dateNonNullable_QueryNullVariableShouldThrow() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val exception = + shouldThrow { nonNullableDate.getAllByTagAndValue(tag, null) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingText "is null" + } + } + + @Test + fun dateNonNullable_QueryOmittedVariableShouldMatchAll() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val testDatas = Arb.dataConnect.threeNonNullDatesTestData().next(rs) + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndMaybeValue(tag) + queryResult.data.items + .map { it.id } + .shouldContainExactlyInAnyOrder( + insertResult.data.key1.id, + insertResult.data.key2.id, + insertResult.data.key3.id + ) + } + + @Test + fun dateNonNullable_QueryNullVariableShouldMatchNone() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val testDatas = Arb.dataConnect.threeNonNullDatesTestData().next(rs) + nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndMaybeValue(tag, null) + queryResult.data.items.shouldBeEmpty() + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type DateNullable @table { value: Date, tag: String } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNullable_MutationVariable() = + runTest(timeout = 1.minutes) { + val localDates = + Arb.dataConnect + .localDate() + .map { it.toJavaLocalDate() } + .orNullableReference(nullProbability = 0.2) + checkAll(propTestConfig, localDates) { localDate -> + val insertResult = nullableDate.insert(localDate.ref) + val queryResult = nullableDate.getByKey(insertResult.data.key) + queryResult.data.item?.value shouldBe localDate.ref + } + } + + @Test + fun dateNullable_QueryVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threePossiblyNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = + nullableDate.getAllByTagAndValue(tag, testDatas.selected?.date?.toJavaLocalDate()) + val matchingIds = testDatas.idsMatchingSelected(insertResult) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNullable_QueryOmittedVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threePossiblyNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = nullableDate.getAllByTagAndMaybeValue(tag) + queryResult.data.items + .map { it.id } + .shouldContainExactlyInAnyOrder( + insertResult.data.key1.id, + insertResult.data.key2.id, + insertResult.data.key3.id + ) + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for default `Date` variable values. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationVariableDefaults() = runTest { + val insertResult = nonNullableDate.insertWithDefaults() + val queryResult = nonNullableDate.getInsertedWithDefaultsByKey(insertResult.data.key) + val item = withClue("queryResult.data.item") { queryResult.data.item.shouldNotBeNull() } + + assertSoftly { + withClue(item) { + withClue("valueWithVariableDefault") { + item.valueWithVariableDefault shouldBe java.time.LocalDate.of(6904, 11, 30) + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe java.time.LocalDate.of(2112, 1, 31) + } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date.toJavaLocalDate() } + withClue("requestTime1") { item.requestTime1.shouldNotBeNull() } + withClue("requestTime2") { item.requestTime2 shouldBe item.requestTime1 } + } + } + + withClue("requestTime validation") { + val today = dataConnect.requestTimeAsDate().toTheeTenAbpJavaLocalDate() + val yesterday = today.minusDays(1) + val tomorrow = today.plusDays(1) + val requestTime = item.requestTime1!!.toTheeTenAbpJavaLocalDate() + requestTime.shouldBeIn(yesterday, today, tomorrow) + } + } + + @Test + fun dateNonNullable_QueryVariableDefaults() = + runTest(timeout = 1.minutes) { + val defaultTestData = DateTestData(java.time.LocalDate.of(2692, 5, 21), "2692-05-21") + val localDateArb = Arb.dataConnect.dateTestData().withEdgecases(defaultTestData) + checkAll( + propTestConfig, + Arb.dataConnect.threeNonNullDatesTestData(localDateArb), + Arb.dataConnect.tag() + ) { testDatas, tag -> + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndDefaultValue(tag) + val matchingIds = + testDatas.idsMatching(insertResult, defaultTestData.date.toJavaLocalDate()) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNullable_MutationVariableDefaults() = runTest { + val insertResult = nullableDate.insertWithDefaults() + val queryResult = nullableDate.getInsertedWithDefaultsByKey(insertResult.data.key) + val item = withClue("queryResult.data.item") { queryResult.data.item.shouldNotBeNull() } + + assertSoftly { + withClue(item) { + withClue("valueWithVariableDefault") { + item.valueWithVariableDefault shouldBe java.time.LocalDate.of(8113, 2, 9) + } + withClue("valueWithVariableNullDefault") { + item.valueWithVariableNullDefault.shouldBeNull() + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe java.time.LocalDate.of(1921, 12, 2) + } + withClue("valueWithSchemaNullDefault") { item.valueWithSchemaNullDefault.shouldBeNull() } + withClue("valueWithNoDefault") { item.valueWithNoDefault.shouldBeNull() } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date.toJavaLocalDate() } + withClue("requestTime1") { item.requestTime1.shouldNotBeNull() } + withClue("requestTime2") { item.requestTime2 shouldBe item.requestTime1 } + } + } + + withClue("requestTime validation") { + val today = dataConnect.requestTimeAsDate().toTheeTenAbpJavaLocalDate() + val yesterday = today.minusDays(1) + val tomorrow = today.plusDays(1) + val requestTime = item.requestTime1!!.toTheeTenAbpJavaLocalDate() + requestTime.shouldBeIn(yesterday, today, tomorrow) + } + } + + @Test + fun dateNullable_QueryVariableDefaults() = + runTest(timeout = 1.minutes) { + val defaultTestData = DateTestData(java.time.LocalDate.of(1771, 10, 28), "1771-10-28") + val dateTestDataArb = + Arb.dataConnect + .dateTestData() + .withEdgecases(defaultTestData) + .orNullableReference(nullProbability = 0.333) + checkAll( + propTestConfig, + Arb.dataConnect.threePossiblyNullDatesTestData(dateTestDataArb), + Arb.dataConnect.tag() + ) { testDatas, tag -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = nullableDate.getAllByTagAndDefaultValue(tag) + val matchingIds = + testDatas.idsMatching(insertResult, defaultTestData.date.toJavaLocalDate()) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Invalid Date String Tests + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig.copy(iterations = 500), Arb.dataConnect.invalidDateScalarString()) { + testData -> + val exception = + shouldThrow { + nonNullableDate.insert(testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNonNullable_QueryInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig.copy(iterations = 500), + Arb.dataConnect.tag(), + Arb.dataConnect.invalidDateScalarString() + ) { tag, testData -> + val exception = + shouldThrow { + nonNullableDate.getAllByTagAndValue(tag = tag, value = testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNullable_MutationInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig.copy(iterations = 500), Arb.dataConnect.invalidDateScalarString()) { + testData -> + val exception = + shouldThrow { nullableDate.insert(testData.toDateScalarString()) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNullable_QueryInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig.copy(iterations = 500), + Arb.dataConnect.tag(), + Arb.dataConnect.invalidDateScalarString() + ) { tag, testData -> + val exception = + shouldThrow { + nullableDate.getAllByTagAndValue(tag = tag, value = testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper methods and classes. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Serializable private data class SingleKeyVariables(val key: Key) + + @Serializable private data class SingleKeyData(val key: Key) + + @Serializable + private data class MultipleKeysData(val items: List) { + @Serializable data class Item(val id: UUID) + } + + @Serializable private data class ThreeKeysData(val key1: Key, val key2: Key, val key3: Key) + + @Serializable private data class InsertVariables(val value: java.time.LocalDate?) + + @Serializable private data class InsertStringVariables(val value: String) + + @Serializable + private data class Insert3Variables( + val tag: String, + val value1: java.time.LocalDate?, + val value2: java.time.LocalDate?, + val value3: java.time.LocalDate?, + ) + + @Serializable private data class TagVariables(val tag: String) + + @Serializable + private data class TagAndValueVariables(val tag: String, val value: java.time.LocalDate?) + + @Serializable private data class TagAndStringValueVariables(val tag: String, val value: String) + + @Serializable + private data class QueryData(val item: Item?) { + @Serializable data class Item(val value: java.time.LocalDate?) + } + + @Serializable + private data class GetInsertedWithDefaultsByKeyQueryData(val item: Item?) { + @Serializable + data class Item( + val valueWithVariableDefault: java.time.LocalDate, + val valueWithVariableNullDefault: java.time.LocalDate?, + val valueWithSchemaDefault: java.time.LocalDate, + val valueWithSchemaNullDefault: java.time.LocalDate?, + val valueWithNoDefault: java.time.LocalDate?, + val epoch: java.time.LocalDate?, + val requestTime1: java.time.LocalDate?, + val requestTime2: java.time.LocalDate?, + ) + } + + @Serializable private data class Key(val id: UUID) + + /** Operations for querying and mutating the table that stores non-nullable Date scalar values. */ + private val nonNullableDate = + Operations( + getByKeyQueryName = "DateNonNullable_GetByKey", + getAllByTagAndValueQueryName = "DateNonNullable_GetAllByTagAndValue", + getAllByTagAndMaybeValueQueryName = "DateNonNullable_GetAllByTagAndMaybeValue", + getAllByTagAndDefaultValueQueryName = "DateNonNullable_GetAllByTagAndDefaultValue", + insertMutationName = "DateNonNullable_Insert", + insert3MutationName = "DateNonNullable_Insert3", + insertWithDefaultsMutationName = "DateNonNullableWithDefaults_Insert", + getInsertedWithDefaultsByKeyQueryName = "DateNonNullableWithDefaults_GetByKey", + ) + + /** Operations for querying and mutating the table that stores nullable Date scalar values. */ + private val nullableDate = + Operations( + getByKeyQueryName = "DateNullable_GetByKey", + getAllByTagAndValueQueryName = "DateNullable_GetAllByTagAndValue", + getAllByTagAndMaybeValueQueryName = "DateNullable_GetAllByTagAndValue", + getAllByTagAndDefaultValueQueryName = "DateNullable_GetAllByTagAndDefaultValue", + insertMutationName = "DateNullable_Insert", + insert3MutationName = "DateNullable_Insert3", + insertWithDefaultsMutationName = "DateNullableWithDefaults_Insert", + getInsertedWithDefaultsByKeyQueryName = "DateNullableWithDefaults_GetByKey", + ) + + private inner class Operations( + getByKeyQueryName: String, + getAllByTagAndValueQueryName: String, + getAllByTagAndMaybeValueQueryName: String, + getAllByTagAndDefaultValueQueryName: String, + insertMutationName: String, + insert3MutationName: String, + insertWithDefaultsMutationName: String, + getInsertedWithDefaultsByKeyQueryName: String, + ) { + + suspend fun insert( + localDate: java.time.LocalDate? + ): MutationResult = insert(InsertVariables(localDate)) + + suspend fun insert(variables: InsertVariables): MutationResult = + mutations.insert(variables).execute() + + suspend fun insert(localDate: String): MutationResult = + insert(InsertStringVariables(localDate)) + + suspend fun insert( + variables: InsertStringVariables + ): MutationResult = mutations.insert(variables).execute() + + suspend fun insert3( + tag: String, + testDatas: ThreeDateTestDatas, + ): MutationResult = + insert3( + tag = tag, + value1 = testDatas.testData1?.date?.toJavaLocalDate(), + value2 = testDatas.testData2?.date?.toJavaLocalDate(), + value3 = testDatas.testData3?.date?.toJavaLocalDate() + ) + + suspend fun insert3( + tag: String, + value1: java.time.LocalDate?, + value2: java.time.LocalDate?, + value3: java.time.LocalDate?, + ): MutationResult = + insert3(Insert3Variables(tag = tag, value1 = value1, value2 = value2, value3 = value3)) + + suspend fun insert3( + variables: Insert3Variables + ): MutationResult = mutations.insert3(variables).execute() + + suspend fun getByKey(key: Key): QueryResult = + getByKey(SingleKeyVariables(key)) + + suspend fun getByKey( + variables: SingleKeyVariables + ): QueryResult = queries.getByKey(variables).execute() + + suspend fun getAllByTagAndValue( + tag: String, + value: java.time.LocalDate? + ): QueryResult = + getAllByTagAndValue(TagAndValueVariables(tag, value)) + + suspend fun getAllByTagAndValue( + variables: TagAndValueVariables + ): QueryResult = + queries.getAllByTagAndValue(variables).execute() + + suspend fun getAllByTagAndValue( + tag: String, + value: String + ): QueryResult = + getAllByTagAndValue(TagAndStringValueVariables(tag, value)) + + suspend fun getAllByTagAndValue( + variables: TagAndStringValueVariables + ): QueryResult = + queries.getAllByTagAndValue(variables).execute() + + suspend fun getAllByTagAndMaybeValue( + tag: String, + ): QueryResult = getAllByTagAndMaybeValue(TagVariables(tag)) + + suspend fun getAllByTagAndMaybeValue( + variables: TagVariables + ): QueryResult = + queries.getAllByTagAndMaybeValue(variables).execute() + + suspend fun getAllByTagAndMaybeValue( + tag: String, + value: Nothing?, + ): QueryResult = + getAllByTagAndMaybeValue(TagAndValueVariables(tag, value)) + + suspend fun getAllByTagAndMaybeValue( + variables: TagAndValueVariables + ): QueryResult = + queries.getAllByTagAndMaybeValue(variables).execute() + + suspend fun getAllByTagAndDefaultValue( + tag: String + ): QueryResult = getAllByTagAndDefaultValue(TagVariables(tag)) + + suspend fun getAllByTagAndDefaultValue( + variables: TagVariables + ): QueryResult = + queries.getAllByTagAndDefaultValue(variables).execute() + + suspend fun insertWithDefaults(): MutationResult = + mutations.insertWithDefaults().execute() + + suspend fun getInsertedWithDefaultsByKey( + key: Key + ): QueryResult = + getInsertedWithDefaultsByKey(SingleKeyVariables(key)) + + suspend fun getInsertedWithDefaultsByKey( + variables: SingleKeyVariables + ): QueryResult = + queries.getInsertedWithDefaultsByKey(variables).execute() + + private val queries = + object { + fun getByKey(variables: SingleKeyVariables): QueryRef = + dataConnect.query( + getByKeyQueryName, + variables, + serializer(), + serializer(), + ) + + inline fun getAllByTagAndValue( + variables: Variables + ): QueryRef = + dataConnect.query( + getAllByTagAndValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndMaybeValue( + variables: TagVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndMaybeValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndMaybeValue( + variables: TagAndValueVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndMaybeValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndDefaultValue( + variables: TagVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndDefaultValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getInsertedWithDefaultsByKey( + variables: SingleKeyVariables + ): QueryRef = + dataConnect.query( + getInsertedWithDefaultsByKeyQueryName, + variables, + serializer(), + serializer(), + ) + } + + private val mutations = + object { + inline fun insert( + variables: Variables + ): MutationRef = + dataConnect.mutation( + insertMutationName, + variables, + serializer(), + serializer(), + ) + + fun insert3(variables: Insert3Variables): MutationRef = + dataConnect.mutation( + insert3MutationName, + variables, + serializer(), + serializer(), + ) + + fun insertWithDefaults(): MutationRef = + dataConnect.mutation( + insertWithDefaultsMutationName, + Unit, + serializer(), + serializer(), + ) + } + } + + private companion object { + val propTestConfig = + PropTestConfig( + iterations = 20, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.5), + ) + + fun ThreeDateTestDatas.idsMatchingSelected( + result: MutationResult + ): List = idsMatchingSelected(result.data) + + fun ThreeDateTestDatas.idsMatchingSelected(data: ThreeKeysData): List = + idsMatchingSelected { + data.uuidFromItemNumber(it) + } + + fun ThreeDateTestDatas.idsMatching( + result: MutationResult, + localDate: java.time.LocalDate?, + ): List = idsMatching(result.data, localDate) + + fun ThreeDateTestDatas.idsMatching( + data: ThreeKeysData, + localDate: java.time.LocalDate?, + ): List = idsMatching(localDate) { data.uuidFromItemNumber(it) } + + fun ThreeKeysData.uuidFromItemNumber(itemNumber: ItemNumber): UUID = + when (itemNumber) { + ItemNumber.ONE -> key1 + ItemNumber.TWO -> key2 + ItemNumber.THREE -> key3 + }.id + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/KotlinxDatetimeLocalDateIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/KotlinxDatetimeLocalDateIntegrationTest.kt new file mode 100644 index 00000000000..a4ea009a7c7 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/KotlinxDatetimeLocalDateIntegrationTest.kt @@ -0,0 +1,720 @@ +/* + * 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, KotlinxDatetimeLocalDateSerializer::class) + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateIntegrationTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO JavaTimeLocalDateIntegrationTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +package com.google.firebase.dataconnect + +import com.google.firebase.dataconnect.serializers.KotlinxDatetimeLocalDateSerializer +import com.google.firebase.dataconnect.serializers.UUIDSerializer +import com.google.firebase.dataconnect.testutil.DataConnectIntegrationTestBase +import com.google.firebase.dataconnect.testutil.property.arbitrary.DateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.EdgeCases +import com.google.firebase.dataconnect.testutil.property.arbitrary.ThreeDateTestDatas +import com.google.firebase.dataconnect.testutil.property.arbitrary.ThreeDateTestDatas.ItemNumber +import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect +import com.google.firebase.dataconnect.testutil.property.arbitrary.dateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.invalidDateScalarString +import com.google.firebase.dataconnect.testutil.property.arbitrary.localDate +import com.google.firebase.dataconnect.testutil.property.arbitrary.orNullableReference +import com.google.firebase.dataconnect.testutil.property.arbitrary.threeNonNullDatesTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.threePossiblyNullDatesTestData +import com.google.firebase.dataconnect.testutil.requestTimeAsDate +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText +import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingTextIgnoringCase +import com.google.firebase.dataconnect.testutil.toTheeTenAbpJavaLocalDate +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.shouldBeIn +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.property.Arb +import io.kotest.property.EdgeConfig +import io.kotest.property.PropTestConfig +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.withEdgecases +import io.kotest.property.checkAll +import java.util.UUID +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.serializer +import org.junit.Test + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateIntegrationTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO JavaTimeLocalDateIntegrationTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +class KotlinxDatetimeLocalDateIntegrationTest : 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 DateNonNullable @table { value: Date!, tag: String } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig, Arb.dataConnect.localDate().map { it.toKotlinxLocalDate() }) { + localDate -> + val insertResult = nonNullableDate.insert(localDate) + val queryResult = nonNullableDate.getByKey(insertResult.data.key) + queryResult.data.item?.value shouldBe localDate + } + } + + @Test + fun dateNonNullable_QueryVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threeNonNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = + nonNullableDate.getAllByTagAndValue(tag, testDatas.selected!!.date.toKotlinxLocalDate()) + val matchingIds = testDatas.idsMatchingSelected(insertResult) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNonNullable_MutationNullVariableShouldThrow() = runTest { + val exception = shouldThrow { nonNullableDate.insert(null) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingText "is null" + } + } + + @Test + fun dateNonNullable_QueryNullVariableShouldThrow() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val exception = + shouldThrow { nonNullableDate.getAllByTagAndValue(tag, null) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingText "is null" + } + } + + @Test + fun dateNonNullable_QueryOmittedVariableShouldMatchAll() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val testDatas = Arb.dataConnect.threeNonNullDatesTestData().next(rs) + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndMaybeValue(tag) + queryResult.data.items + .map { it.id } + .shouldContainExactlyInAnyOrder( + insertResult.data.key1.id, + insertResult.data.key2.id, + insertResult.data.key3.id + ) + } + + @Test + fun dateNonNullable_QueryNullVariableShouldMatchNone() = runTest { + val tag = Arb.dataConnect.tag().next(rs) + val testDatas = Arb.dataConnect.threeNonNullDatesTestData().next(rs) + nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndMaybeValue(tag, null) + queryResult.data.items.shouldBeEmpty() + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for inserting into and querying this table: + // type DateNullable @table { value: Date, tag: String } + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNullable_MutationVariable() = + runTest(timeout = 1.minutes) { + val localDates = + Arb.dataConnect + .localDate() + .map { it.toKotlinxLocalDate() } + .orNullableReference(nullProbability = 0.2) + checkAll(propTestConfig, localDates) { localDate -> + val insertResult = nullableDate.insert(localDate.ref) + val queryResult = nullableDate.getByKey(insertResult.data.key) + queryResult.data.item?.value shouldBe localDate.ref + } + } + + @Test + fun dateNullable_QueryVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threePossiblyNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = + nullableDate.getAllByTagAndValue(tag, testDatas.selected?.date?.toKotlinxLocalDate()) + val matchingIds = testDatas.idsMatchingSelected(insertResult) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNullable_QueryOmittedVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig, + Arb.dataConnect.tag(), + Arb.dataConnect.threePossiblyNullDatesTestData() + ) { tag, testDatas -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = nullableDate.getAllByTagAndMaybeValue(tag) + queryResult.data.items + .map { it.id } + .shouldContainExactlyInAnyOrder( + insertResult.data.key1.id, + insertResult.data.key2.id, + insertResult.data.key3.id + ) + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Tests for default `Date` variable values. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationVariableDefaults() = runTest { + val insertResult = nonNullableDate.insertWithDefaults() + val queryResult = nonNullableDate.getInsertedWithDefaultsByKey(insertResult.data.key) + val item = withClue("queryResult.data.item") { queryResult.data.item.shouldNotBeNull() } + + assertSoftly { + withClue(item) { + withClue("valueWithVariableDefault") { + item.valueWithVariableDefault shouldBe kotlinx.datetime.LocalDate(6904, 11, 30) + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe kotlinx.datetime.LocalDate(2112, 1, 31) + } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date.toKotlinxLocalDate() } + withClue("requestTime1") { item.requestTime1.shouldNotBeNull() } + withClue("requestTime2") { item.requestTime2 shouldBe item.requestTime1 } + } + } + + withClue("requestTime validation") { + val today = dataConnect.requestTimeAsDate().toTheeTenAbpJavaLocalDate() + val yesterday = today.minusDays(1) + val tomorrow = today.plusDays(1) + val requestTime = item.requestTime1!!.toTheeTenAbpJavaLocalDate() + requestTime.shouldBeIn(yesterday, today, tomorrow) + } + } + + @Test + fun dateNonNullable_QueryVariableDefaults() = + runTest(timeout = 1.minutes) { + val defaultTestData = DateTestData(kotlinx.datetime.LocalDate(2692, 5, 21), "2692-05-21") + val localDateArb = Arb.dataConnect.dateTestData().withEdgecases(defaultTestData) + checkAll( + propTestConfig, + Arb.dataConnect.threeNonNullDatesTestData(localDateArb), + Arb.dataConnect.tag() + ) { testDatas, tag -> + val insertResult = nonNullableDate.insert3(tag, testDatas) + val queryResult = nonNullableDate.getAllByTagAndDefaultValue(tag) + val matchingIds = + testDatas.idsMatching(insertResult, defaultTestData.date.toKotlinxLocalDate()) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + @Test + fun dateNullable_MutationVariableDefaults() = runTest { + val insertResult = nullableDate.insertWithDefaults() + val queryResult = nullableDate.getInsertedWithDefaultsByKey(insertResult.data.key) + val item = withClue("queryResult.data.item") { queryResult.data.item.shouldNotBeNull() } + + assertSoftly { + withClue(item) { + withClue("valueWithVariableDefault") { + item.valueWithVariableDefault shouldBe kotlinx.datetime.LocalDate(8113, 2, 9) + } + withClue("valueWithVariableNullDefault") { + item.valueWithVariableNullDefault.shouldBeNull() + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe kotlinx.datetime.LocalDate(1921, 12, 2) + } + withClue("valueWithSchemaNullDefault") { item.valueWithSchemaNullDefault.shouldBeNull() } + withClue("valueWithNoDefault") { item.valueWithNoDefault.shouldBeNull() } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date.toKotlinxLocalDate() } + withClue("requestTime1") { item.requestTime1.shouldNotBeNull() } + withClue("requestTime2") { item.requestTime2 shouldBe item.requestTime1 } + } + } + + withClue("requestTime validation") { + val today = dataConnect.requestTimeAsDate().toTheeTenAbpJavaLocalDate() + val yesterday = today.minusDays(1) + val tomorrow = today.plusDays(1) + val requestTime = item.requestTime1!!.toTheeTenAbpJavaLocalDate() + requestTime.shouldBeIn(yesterday, today, tomorrow) + } + } + + @Test + fun dateNullable_QueryVariableDefaults() = + runTest(timeout = 1.minutes) { + val defaultTestData = DateTestData(kotlinx.datetime.LocalDate(1771, 10, 28), "1771-10-28") + val dateTestDataArb = + Arb.dataConnect + .dateTestData() + .withEdgecases(defaultTestData) + .orNullableReference(nullProbability = 0.333) + checkAll( + propTestConfig, + Arb.dataConnect.threePossiblyNullDatesTestData(dateTestDataArb), + Arb.dataConnect.tag() + ) { testDatas, tag -> + val insertResult = nullableDate.insert3(tag, testDatas) + val queryResult = nullableDate.getAllByTagAndDefaultValue(tag) + val matchingIds = + testDatas.idsMatching(insertResult, defaultTestData.date.toKotlinxLocalDate()) + queryResult.data.items.map { it.id } shouldContainExactlyInAnyOrder matchingIds + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Invalid Date String Tests + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Test + fun dateNonNullable_MutationInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig.copy(iterations = 500), Arb.dataConnect.invalidDateScalarString()) { + testData -> + val exception = + shouldThrow { + nonNullableDate.insert(testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNonNullable_QueryInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig.copy(iterations = 500), + Arb.dataConnect.tag(), + Arb.dataConnect.invalidDateScalarString() + ) { tag, testData -> + val exception = + shouldThrow { + nonNullableDate.getAllByTagAndValue(tag = tag, value = testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNullable_MutationInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll(propTestConfig.copy(iterations = 500), Arb.dataConnect.invalidDateScalarString()) { + testData -> + val exception = + shouldThrow { nullableDate.insert(testData.toDateScalarString()) } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + @Test + fun dateNullable_QueryInvalidDateVariable() = + runTest(timeout = 1.minutes) { + checkAll( + propTestConfig.copy(iterations = 500), + Arb.dataConnect.tag(), + Arb.dataConnect.invalidDateScalarString() + ) { tag, testData -> + val exception = + shouldThrow { + nullableDate.getAllByTagAndValue(tag = tag, value = testData.toDateScalarString()) + } + assertSoftly { + exception.message shouldContainWithNonAbuttingText "\$value" + exception.message shouldContainWithNonAbuttingTextIgnoringCase "invalid" + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper methods and classes. + ////////////////////////////////////////////////////////////////////////////////////////////////// + + @Serializable private data class SingleKeyVariables(val key: Key) + + @Serializable private data class SingleKeyData(val key: Key) + + @Serializable + private data class MultipleKeysData(val items: List) { + @Serializable data class Item(val id: UUID) + } + + @Serializable private data class ThreeKeysData(val key1: Key, val key2: Key, val key3: Key) + + @Serializable private data class InsertVariables(val value: kotlinx.datetime.LocalDate?) + + @Serializable private data class InsertStringVariables(val value: String) + + @Serializable + private data class Insert3Variables( + val tag: String, + val value1: kotlinx.datetime.LocalDate?, + val value2: kotlinx.datetime.LocalDate?, + val value3: kotlinx.datetime.LocalDate?, + ) + + @Serializable private data class TagVariables(val tag: String) + + @Serializable + private data class TagAndValueVariables(val tag: String, val value: kotlinx.datetime.LocalDate?) + + @Serializable private data class TagAndStringValueVariables(val tag: String, val value: String) + + @Serializable + private data class QueryData(val item: Item?) { + @Serializable data class Item(val value: kotlinx.datetime.LocalDate?) + } + + @Serializable + private data class GetInsertedWithDefaultsByKeyQueryData(val item: Item?) { + @Serializable + data class Item( + val valueWithVariableDefault: kotlinx.datetime.LocalDate, + val valueWithVariableNullDefault: kotlinx.datetime.LocalDate?, + val valueWithSchemaDefault: kotlinx.datetime.LocalDate, + val valueWithSchemaNullDefault: kotlinx.datetime.LocalDate?, + val valueWithNoDefault: kotlinx.datetime.LocalDate?, + val epoch: kotlinx.datetime.LocalDate?, + val requestTime1: kotlinx.datetime.LocalDate?, + val requestTime2: kotlinx.datetime.LocalDate?, + ) + } + + @Serializable private data class Key(val id: UUID) + + /** Operations for querying and mutating the table that stores non-nullable Date scalar values. */ + private val nonNullableDate = + Operations( + getByKeyQueryName = "DateNonNullable_GetByKey", + getAllByTagAndValueQueryName = "DateNonNullable_GetAllByTagAndValue", + getAllByTagAndMaybeValueQueryName = "DateNonNullable_GetAllByTagAndMaybeValue", + getAllByTagAndDefaultValueQueryName = "DateNonNullable_GetAllByTagAndDefaultValue", + insertMutationName = "DateNonNullable_Insert", + insert3MutationName = "DateNonNullable_Insert3", + insertWithDefaultsMutationName = "DateNonNullableWithDefaults_Insert", + getInsertedWithDefaultsByKeyQueryName = "DateNonNullableWithDefaults_GetByKey", + ) + + /** Operations for querying and mutating the table that stores nullable Date scalar values. */ + private val nullableDate = + Operations( + getByKeyQueryName = "DateNullable_GetByKey", + getAllByTagAndValueQueryName = "DateNullable_GetAllByTagAndValue", + getAllByTagAndMaybeValueQueryName = "DateNullable_GetAllByTagAndValue", + getAllByTagAndDefaultValueQueryName = "DateNullable_GetAllByTagAndDefaultValue", + insertMutationName = "DateNullable_Insert", + insert3MutationName = "DateNullable_Insert3", + insertWithDefaultsMutationName = "DateNullableWithDefaults_Insert", + getInsertedWithDefaultsByKeyQueryName = "DateNullableWithDefaults_GetByKey", + ) + + private inner class Operations( + getByKeyQueryName: String, + getAllByTagAndValueQueryName: String, + getAllByTagAndMaybeValueQueryName: String, + getAllByTagAndDefaultValueQueryName: String, + insertMutationName: String, + insert3MutationName: String, + insertWithDefaultsMutationName: String, + getInsertedWithDefaultsByKeyQueryName: String, + ) { + + suspend fun insert( + localDate: kotlinx.datetime.LocalDate? + ): MutationResult = insert(InsertVariables(localDate)) + + suspend fun insert(variables: InsertVariables): MutationResult = + mutations.insert(variables).execute() + + suspend fun insert(localDate: String): MutationResult = + insert(InsertStringVariables(localDate)) + + suspend fun insert( + variables: InsertStringVariables + ): MutationResult = mutations.insert(variables).execute() + + suspend fun insert3( + tag: String, + testDatas: ThreeDateTestDatas, + ): MutationResult = + insert3( + tag = tag, + value1 = testDatas.testData1?.date?.toKotlinxLocalDate(), + value2 = testDatas.testData2?.date?.toKotlinxLocalDate(), + value3 = testDatas.testData3?.date?.toKotlinxLocalDate() + ) + + suspend fun insert3( + tag: String, + value1: kotlinx.datetime.LocalDate?, + value2: kotlinx.datetime.LocalDate?, + value3: kotlinx.datetime.LocalDate?, + ): MutationResult = + insert3(Insert3Variables(tag = tag, value1 = value1, value2 = value2, value3 = value3)) + + suspend fun insert3( + variables: Insert3Variables + ): MutationResult = mutations.insert3(variables).execute() + + suspend fun getByKey(key: Key): QueryResult = + getByKey(SingleKeyVariables(key)) + + suspend fun getByKey( + variables: SingleKeyVariables + ): QueryResult = queries.getByKey(variables).execute() + + suspend fun getAllByTagAndValue( + tag: String, + value: kotlinx.datetime.LocalDate? + ): QueryResult = + getAllByTagAndValue(TagAndValueVariables(tag, value)) + + suspend fun getAllByTagAndValue( + variables: TagAndValueVariables + ): QueryResult = + queries.getAllByTagAndValue(variables).execute() + + suspend fun getAllByTagAndValue( + tag: String, + value: String + ): QueryResult = + getAllByTagAndValue(TagAndStringValueVariables(tag, value)) + + suspend fun getAllByTagAndValue( + variables: TagAndStringValueVariables + ): QueryResult = + queries.getAllByTagAndValue(variables).execute() + + suspend fun getAllByTagAndMaybeValue( + tag: String, + ): QueryResult = getAllByTagAndMaybeValue(TagVariables(tag)) + + suspend fun getAllByTagAndMaybeValue( + variables: TagVariables + ): QueryResult = + queries.getAllByTagAndMaybeValue(variables).execute() + + suspend fun getAllByTagAndMaybeValue( + tag: String, + value: Nothing?, + ): QueryResult = + getAllByTagAndMaybeValue(TagAndValueVariables(tag, value)) + + suspend fun getAllByTagAndMaybeValue( + variables: TagAndValueVariables + ): QueryResult = + queries.getAllByTagAndMaybeValue(variables).execute() + + suspend fun getAllByTagAndDefaultValue( + tag: String + ): QueryResult = getAllByTagAndDefaultValue(TagVariables(tag)) + + suspend fun getAllByTagAndDefaultValue( + variables: TagVariables + ): QueryResult = + queries.getAllByTagAndDefaultValue(variables).execute() + + suspend fun insertWithDefaults(): MutationResult = + mutations.insertWithDefaults().execute() + + suspend fun getInsertedWithDefaultsByKey( + key: Key + ): QueryResult = + getInsertedWithDefaultsByKey(SingleKeyVariables(key)) + + suspend fun getInsertedWithDefaultsByKey( + variables: SingleKeyVariables + ): QueryResult = + queries.getInsertedWithDefaultsByKey(variables).execute() + + private val queries = + object { + fun getByKey(variables: SingleKeyVariables): QueryRef = + dataConnect.query( + getByKeyQueryName, + variables, + serializer(), + serializer(), + ) + + inline fun getAllByTagAndValue( + variables: Variables + ): QueryRef = + dataConnect.query( + getAllByTagAndValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndMaybeValue( + variables: TagVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndMaybeValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndMaybeValue( + variables: TagAndValueVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndMaybeValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getAllByTagAndDefaultValue( + variables: TagVariables + ): QueryRef = + dataConnect.query( + getAllByTagAndDefaultValueQueryName, + variables, + serializer(), + serializer(), + ) + + fun getInsertedWithDefaultsByKey( + variables: SingleKeyVariables + ): QueryRef = + dataConnect.query( + getInsertedWithDefaultsByKeyQueryName, + variables, + serializer(), + serializer(), + ) + } + + private val mutations = + object { + inline fun insert( + variables: Variables + ): MutationRef = + dataConnect.mutation( + insertMutationName, + variables, + serializer(), + serializer(), + ) + + fun insert3(variables: Insert3Variables): MutationRef = + dataConnect.mutation( + insert3MutationName, + variables, + serializer(), + serializer(), + ) + + fun insertWithDefaults(): MutationRef = + dataConnect.mutation( + insertWithDefaultsMutationName, + Unit, + serializer(), + serializer(), + ) + } + } + + private companion object { + val propTestConfig = + PropTestConfig( + iterations = 20, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.5), + ) + + fun ThreeDateTestDatas.idsMatchingSelected( + result: MutationResult + ): List = idsMatchingSelected(result.data) + + fun ThreeDateTestDatas.idsMatchingSelected(data: ThreeKeysData): List = + idsMatchingSelected { + data.uuidFromItemNumber(it) + } + + fun ThreeDateTestDatas.idsMatching( + result: MutationResult, + localDate: kotlinx.datetime.LocalDate?, + ): List = idsMatching(result.data, localDate) + + fun ThreeDateTestDatas.idsMatching( + data: ThreeKeysData, + localDate: kotlinx.datetime.LocalDate?, + ): List = idsMatching(localDate) { data.uuidFromItemNumber(it) } + + fun ThreeKeysData.uuidFromItemNumber(itemNumber: ItemNumber): UUID = + when (itemNumber) { + ItemNumber.ONE -> key1 + ItemNumber.TWO -> key2 + ItemNumber.THREE -> key3 + }.id + } +} diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/LocalDateIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/LocalDateIntegrationTest.kt index 05055ba4e39..490fea04961 100644 --- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/LocalDateIntegrationTest.kt +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/LocalDateIntegrationTest.kt @@ -17,6 +17,12 @@ @file:OptIn(ExperimentalKotest::class) @file:UseSerializers(UUIDSerializer::class) +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED TO JavaTimeLocalDateIntegrationTest.kt and +// KotlinxDatetimeLocalDateIntegrationTest.kt AND ADAPTED TO TEST THE +// CORRESPONDING IMPLEMENTATIONS OF LocalDate. ANY CHANGES MADE TO THIS FILE +// MUST ALSO BE PORTED TO THOSE OTHER FILES, IF APPROPRIATE. +//////////////////////////////////////////////////////////////////////////////// package com.google.firebase.dataconnect import com.google.firebase.dataconnect.serializers.UUIDSerializer @@ -60,6 +66,12 @@ import kotlinx.serialization.UseSerializers import kotlinx.serialization.serializer import org.junit.Test +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED TO JavaTimeLocalDateIntegrationTest.kt and +// KotlinxDatetimeLocalDateIntegrationTest.kt AND ADAPTED TO TEST THE +// CORRESPONDING IMPLEMENTATIONS OF LocalDate. ANY CHANGES MADE TO THIS FILE +// MUST ALSO BE PORTED TO THOSE OTHER FILES, IF APPROPRIATE. +//////////////////////////////////////////////////////////////////////////////// class LocalDateIntegrationTest : DataConnectIntegrationTestBase() { private val dataConnect: FirebaseDataConnect by lazy { diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializer.kt new file mode 100644 index 00000000000..0affaab22ce --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializer.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.serializers + +import com.google.firebase.dataconnect.toDataConnectLocalDate +import com.google.firebase.dataconnect.toJavaLocalDate +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 [java.time.LocalDate] + * objects in the wire format expected by the Firebase Data Connect backend. + * + * Be sure to _only_ call this method if [java.time.LocalDate] is available. See the documentation + * for [toJavaLocalDate] for details. + * + * @see LocalDateSerializer + * @see KotlinxDatetimeLocalDateSerializer + */ +public object JavaTimeLocalDateSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.LocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: java.time.LocalDate) { + LocalDateSerializer.serialize(encoder, value.toDataConnectLocalDate()) + } + + override fun deserialize(decoder: Decoder): java.time.LocalDate { + return LocalDateSerializer.deserialize(decoder).toJavaLocalDate() + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializer.kt new file mode 100644 index 00000000000..a1006bae889 --- /dev/null +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializer.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.serializers + +import com.google.firebase.dataconnect.toDataConnectLocalDate +import com.google.firebase.dataconnect.toKotlinxLocalDate +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 [kotlinx.datetime.LocalDate] + * objects in the wire format expected by the Firebase Data Connect backend. + * + * Be sure to _only_ use this class if your application has a dependency on + * `org.jetbrains.kotlinx:kotlinx-datetime`. See the documentation for [toKotlinxLocalDate] for + * details. + * + * @see LocalDateSerializer + * @see JavaTimeLocalDateSerializer + */ +public object KotlinxDatetimeLocalDateSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("kotlinx.datetime.LocalDate", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: kotlinx.datetime.LocalDate) { + LocalDateSerializer.serialize(encoder, value.toDataConnectLocalDate()) + } + + override fun deserialize(decoder: Decoder): kotlinx.datetime.LocalDate { + return LocalDateSerializer.deserialize(decoder).toKotlinxLocalDate() + } +} diff --git a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializer.kt b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializer.kt index 0b699a1a6d3..22cb087cd3c 100644 --- a/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializer.kt +++ b/firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializer.kt @@ -29,6 +29,9 @@ import kotlinx.serialization.encoding.Encoder /** * An implementation of [KSerializer] for serializing and deserializing [LocalDate] objects in the * wire format expected by the Firebase Data Connect backend. + * + * @see JavaTimeLocalDateSerializer + * @see KotlinxDatetimeLocalDateSerializer */ public object LocalDateSerializer : KSerializer { diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializerUnitTest.kt new file mode 100644 index 00000000000..4b46994985f --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/JavaTimeLocalDateSerializerUnitTest.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. + */ +@file:OptIn(ExperimentalKotest::class) + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateSerializerUnitTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO KotlinxDatetimeLocalDateSerializerUnitTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +package com.google.firebase.dataconnect.serializers + +import com.google.firebase.dataconnect.testutil.dayRangeInYear +import com.google.firebase.dataconnect.testutil.property.arbitrary.intWithEvenNumDigitsDistribution +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromValue +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToValue +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import io.kotest.assertions.throwables.shouldThrow +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.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.booleanArray +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.cyrillic +import io.kotest.property.arbitrary.egyptianHieroglyphs +import io.kotest.property.arbitrary.enum +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.greekCoptic +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.katakana +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.merge +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.triple +import io.kotest.property.arbitrary.withEdgecases +import io.kotest.property.checkAll +import io.mockk.every +import io.mockk.mockk +import kotlin.random.nextInt +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encoding.Decoder +import org.junit.Test + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateSerializerUnitTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO KotlinxDatetimeLocalDateSerializerUnitTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +class JavaTimeLocalDateSerializerUnitTest { + + @Test + fun `serialize() should produce the expected serialized string`() = runTest { + checkAll(propTestConfig, Arb.localDate()) { localDate -> + val value = encodeToValue(localDate, JavaTimeLocalDateSerializer, serializersModule = null) + value.stringValue shouldBe localDate.toYYYYMMDDWithZeroPadding() + } + } + + @Test + fun `deserialize() should produce the expected LocalDate object`() = runTest { + val numPaddingCharsArb = Arb.int(0..10) + val arb = Arb.triple(numPaddingCharsArb, numPaddingCharsArb, numPaddingCharsArb) + checkAll(propTestConfig, Arb.localDate(), arb) { localDate, paddingCharsTriple -> + val (yearPadding, monthPadding, dayPadding) = paddingCharsTriple + val value = + localDate + .toYYYYMMDDWithZeroPadding( + yearPadding = yearPadding, + monthPadding = monthPadding, + dayPadding = dayPadding + ) + .toValueProto() + + val decodedLocalDate = + decodeFromValue(value, JavaTimeLocalDateSerializer, serializersModule = null) + decodedLocalDate shouldBe localDate + } + } + + @Test + fun `deserialize() should throw IllegalArgumentException when given unparseable strings`() = + runTest { + checkAll(propTestConfig, Arb.unparseableDate()) { encodedDate -> + val decoder: Decoder = mockk { every { decodeString() } returns encodedDate } + shouldThrow { JavaTimeLocalDateSerializer.deserialize(decoder) } + } + } + + private companion object { + val propTestConfig = + PropTestConfig( + iterations = 500, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.2) + ) + + fun java.time.LocalDate.toYYYYMMDDWithZeroPadding( + yearPadding: Int = 4, + monthPadding: Int = 2, + dayPadding: Int = 2, + ): String { + val yearString = year.toZeroPaddedString(yearPadding) + val monthString = month.value.toZeroPaddedString(monthPadding) + val dayString = dayOfMonth.toZeroPaddedString(dayPadding) + return "$yearString-$monthString-$dayString" + } + + fun Int.toZeroPaddedString(length: Int): String = buildString { + append(this@toZeroPaddedString) + val signChar = + firstOrNull()?.let { + if (it == '-') { + deleteCharAt(0) + it + } else { + null + } + } + + while (this.length < length) { + insert(0, '0') + } + + if (signChar !== null) { + insert(0, signChar) + } + } + + fun Arb.Companion.localDate( + year: Arb = + intWithEvenNumDigitsDistribution(java.time.Year.MIN_VALUE..java.time.Year.MAX_VALUE), + month: Arb = intWithEvenNumDigitsDistribution(1..12), + day: Arb = intWithEvenNumDigitsDistribution(1..31), + ): Arb { + fun Int.coerceDayOfMonthIntoValidRangeFor(month: Int, year: Int): Int { + val monthObject = org.threeten.bp.Month.of(month) + val yearObject = org.threeten.bp.Year.of(year) + val dayRange = monthObject.dayRangeInYear(yearObject) + return coerceIn(dayRange) + } + return arbitrary( + edgecaseFn = { rs -> + val yearInt = if (rs.random.nextBoolean()) year.next(rs) else year.edgecase(rs)!! + val monthInt = if (rs.random.nextBoolean()) month.next(rs) else month.edgecase(rs)!! + val dayInt = if (rs.random.nextBoolean()) day.next(rs) else day.edgecase(rs)!! + val coercedDayInt = + dayInt.coerceDayOfMonthIntoValidRangeFor(month = monthInt, year = yearInt) + java.time.LocalDate.of(yearInt, monthInt, coercedDayInt) + }, + sampleFn = { + val yearInt = year.bind() + val monthInt = month.bind() + val dayInt = day.bind() + val coercedDayInt = + dayInt.coerceDayOfMonthIntoValidRangeFor(month = monthInt, year = yearInt) + java.time.LocalDate.of(yearInt, monthInt, coercedDayInt) + } + ) + } + + private enum class UnparseableNumberReason { + EmptyString, + InvalidChars, + GreaterThanIntMax, + LessThanIntMin, + } + + private val codepoints = + Codepoint.ascii() + .merge(Codepoint.egyptianHieroglyphs()) + .merge(Codepoint.arabic()) + .merge(Codepoint.cyrillic()) + .merge(Codepoint.greekCoptic()) + .merge(Codepoint.katakana()) + + fun Arb.Companion.unparseableNumber(): Arb { + val reasonArb = enum() + val validIntArb = intWithEvenNumDigitsDistribution(0..Int.MAX_VALUE) + val validChars = listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-').map { it.code } + val invalidString = + string(1..5, codepoints.filterNot { validChars.contains(it.value) }).withEdgecases("-") + val tooLargeValues = long(Int.MAX_VALUE.toLong() + 1L..Long.MAX_VALUE) + val tooSmallValues = long(Long.MIN_VALUE until Int.MIN_VALUE.toLong()) + return arbitrary { rs -> + when (reasonArb.bind()) { + UnparseableNumberReason.EmptyString -> "" + UnparseableNumberReason.GreaterThanIntMax -> "${tooLargeValues.bind()}" + UnparseableNumberReason.LessThanIntMin -> "${tooSmallValues.bind()}" + UnparseableNumberReason.InvalidChars -> { + val flags = Array(3) { rs.random.nextBoolean() } + if (!flags[0]) { + flags[2] = true + } + val prefix = if (flags[0]) invalidString.bind() else "" + val mid = if (flags[1]) validIntArb.bind() else "" + val suffix = if (flags[2]) invalidString.bind() else "" + "$prefix$mid$suffix" + } + } + } + } + + fun Arb.Companion.unparseableDash(): Arb { + val invalidString = string(1..5, codepoints.filterNot { it.value == '-'.code }) + return arbitrary { rs -> + val flags = Array(3) { rs.random.nextBoolean() } + if (!flags[0]) { + flags[2] = true + } + + val prefix = if (flags[0]) invalidString.bind() else "" + val mid = if (flags[1]) "-" else "" + val suffix = if (flags[2]) invalidString.bind() else "" + + "$prefix$mid$suffix" + } + } + + fun Arb.Companion.unparseableDate(): Arb { + val validNumber = intWithEvenNumDigitsDistribution(0..Int.MAX_VALUE) + val unparseableNumber = unparseableNumber() + val unparseableDash = unparseableDash() + val booleanArray = booleanArray(Arb.constant(5), Arb.boolean()) + return arbitrary(edgecases = listOf("", "-", "--", "---")) { rs -> + val invalidCharFlags = booleanArray.bind() + if (invalidCharFlags.count { it } == 0) { + invalidCharFlags[rs.random.nextInt(invalidCharFlags.indices)] = true + } + + val year = if (invalidCharFlags[0]) unparseableNumber.bind() else validNumber.bind() + val dash1 = if (invalidCharFlags[1]) unparseableDash.bind() else "-" + val month = if (invalidCharFlags[2]) unparseableNumber.bind() else validNumber.bind() + val dash2 = if (invalidCharFlags[3]) unparseableDash.bind() else "-" + val day = if (invalidCharFlags[4]) unparseableNumber.bind() else validNumber.bind() + + "$year$dash1$month$dash2$day" + } + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializerUnitTest.kt new file mode 100644 index 00000000000..3ab96a648a9 --- /dev/null +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/KotlinxDatetimeLocalDateSerializerUnitTest.kt @@ -0,0 +1,264 @@ +/* + * 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) + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateSerializerUnitTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO JavaTimeLocalDateSerializerUnitTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +package com.google.firebase.dataconnect.serializers + +import com.google.firebase.dataconnect.testutil.dayRangeInYear +import com.google.firebase.dataconnect.testutil.property.arbitrary.intWithEvenNumDigitsDistribution +import com.google.firebase.dataconnect.util.ProtoUtil.decodeFromValue +import com.google.firebase.dataconnect.util.ProtoUtil.encodeToValue +import com.google.firebase.dataconnect.util.ProtoUtil.toValueProto +import io.kotest.assertions.throwables.shouldThrow +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.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.booleanArray +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.cyrillic +import io.kotest.property.arbitrary.egyptianHieroglyphs +import io.kotest.property.arbitrary.enum +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.greekCoptic +import io.kotest.property.arbitrary.int +import io.kotest.property.arbitrary.katakana +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.merge +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.triple +import io.kotest.property.arbitrary.withEdgecases +import io.kotest.property.checkAll +import io.mockk.every +import io.mockk.mockk +import kotlin.random.nextInt +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encoding.Decoder +import org.junit.Test + +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED AND ADAPTED FROM LocalDateSerializerUnitTest.kt +// MAKE SURE THAT ANY CHANGES TO THIS FILE ARE BACKPORTED TO +// LocalDateIntegrationTest.kt AND PORTED TO JavaTimeLocalDateSerializerUnitTest.kt, +// if appropriate. +//////////////////////////////////////////////////////////////////////////////// +class KotlinxDatetimeLocalDateSerializerUnitTest { + + @Test + fun `serialize() should produce the expected serialized string`() = runTest { + checkAll(propTestConfig, Arb.localDate()) { localDate -> + val value = + encodeToValue(localDate, KotlinxDatetimeLocalDateSerializer, serializersModule = null) + value.stringValue shouldBe localDate.toYYYYMMDDWithZeroPadding() + } + } + + @Test + fun `deserialize() should produce the expected LocalDate object`() = runTest { + val numPaddingCharsArb = Arb.int(0..10) + val arb = Arb.triple(numPaddingCharsArb, numPaddingCharsArb, numPaddingCharsArb) + checkAll(propTestConfig, Arb.localDate(), arb) { localDate, paddingCharsTriple -> + val (yearPadding, monthPadding, dayPadding) = paddingCharsTriple + val value = + localDate + .toYYYYMMDDWithZeroPadding( + yearPadding = yearPadding, + monthPadding = monthPadding, + dayPadding = dayPadding + ) + .toValueProto() + + val decodedLocalDate = + decodeFromValue(value, KotlinxDatetimeLocalDateSerializer, serializersModule = null) + decodedLocalDate shouldBe localDate + } + } + + @Test + fun `deserialize() should throw IllegalArgumentException when given unparseable strings`() = + runTest { + checkAll(propTestConfig, Arb.unparseableDate()) { encodedDate -> + val decoder: Decoder = mockk { every { decodeString() } returns encodedDate } + shouldThrow { + KotlinxDatetimeLocalDateSerializer.deserialize(decoder) + } + } + } + + private companion object { + val propTestConfig = + PropTestConfig( + iterations = 500, + edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.2) + ) + + fun kotlinx.datetime.LocalDate.toYYYYMMDDWithZeroPadding( + yearPadding: Int = 4, + monthPadding: Int = 2, + dayPadding: Int = 2, + ): String { + val yearString = year.toZeroPaddedString(yearPadding) + val monthString = month.value.toZeroPaddedString(monthPadding) + val dayString = dayOfMonth.toZeroPaddedString(dayPadding) + return "$yearString-$monthString-$dayString" + } + + fun Int.toZeroPaddedString(length: Int): String = buildString { + append(this@toZeroPaddedString) + val signChar = + firstOrNull()?.let { + if (it == '-') { + deleteCharAt(0) + it + } else { + null + } + } + + while (this.length < length) { + insert(0, '0') + } + + if (signChar !== null) { + insert(0, signChar) + } + } + + fun Arb.Companion.localDate( + year: Arb = + intWithEvenNumDigitsDistribution(java.time.Year.MIN_VALUE..java.time.Year.MAX_VALUE), + month: Arb = intWithEvenNumDigitsDistribution(1..12), + day: Arb = intWithEvenNumDigitsDistribution(1..31), + ): Arb { + fun Int.coerceDayOfMonthIntoValidRangeFor(month: Int, year: Int): Int { + val monthObject = org.threeten.bp.Month.of(month) + val yearObject = org.threeten.bp.Year.of(year) + val dayRange = monthObject.dayRangeInYear(yearObject) + return coerceIn(dayRange) + } + return arbitrary( + edgecaseFn = { rs -> + val yearInt = if (rs.random.nextBoolean()) year.next(rs) else year.edgecase(rs)!! + val monthInt = if (rs.random.nextBoolean()) month.next(rs) else month.edgecase(rs)!! + val dayInt = if (rs.random.nextBoolean()) day.next(rs) else day.edgecase(rs)!! + val coercedDayInt = + dayInt.coerceDayOfMonthIntoValidRangeFor(month = monthInt, year = yearInt) + kotlinx.datetime.LocalDate(yearInt, monthInt, coercedDayInt) + }, + sampleFn = { + val yearInt = year.bind() + val monthInt = month.bind() + val dayInt = day.bind() + val coercedDayInt = + dayInt.coerceDayOfMonthIntoValidRangeFor(month = monthInt, year = yearInt) + kotlinx.datetime.LocalDate(yearInt, monthInt, coercedDayInt) + } + ) + } + + private enum class UnparseableNumberReason { + EmptyString, + InvalidChars, + GreaterThanIntMax, + LessThanIntMin, + } + + private val codepoints = + Codepoint.ascii() + .merge(Codepoint.egyptianHieroglyphs()) + .merge(Codepoint.arabic()) + .merge(Codepoint.cyrillic()) + .merge(Codepoint.greekCoptic()) + .merge(Codepoint.katakana()) + + fun Arb.Companion.unparseableNumber(): Arb { + val reasonArb = enum() + val validIntArb = intWithEvenNumDigitsDistribution(0..Int.MAX_VALUE) + val validChars = listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-').map { it.code } + val invalidString = + string(1..5, codepoints.filterNot { validChars.contains(it.value) }).withEdgecases("-") + val tooLargeValues = long(Int.MAX_VALUE.toLong() + 1L..Long.MAX_VALUE) + val tooSmallValues = long(Long.MIN_VALUE until Int.MIN_VALUE.toLong()) + return arbitrary { rs -> + when (reasonArb.bind()) { + UnparseableNumberReason.EmptyString -> "" + UnparseableNumberReason.GreaterThanIntMax -> "${tooLargeValues.bind()}" + UnparseableNumberReason.LessThanIntMin -> "${tooSmallValues.bind()}" + UnparseableNumberReason.InvalidChars -> { + val flags = Array(3) { rs.random.nextBoolean() } + if (!flags[0]) { + flags[2] = true + } + val prefix = if (flags[0]) invalidString.bind() else "" + val mid = if (flags[1]) validIntArb.bind() else "" + val suffix = if (flags[2]) invalidString.bind() else "" + "$prefix$mid$suffix" + } + } + } + } + + fun Arb.Companion.unparseableDash(): Arb { + val invalidString = string(1..5, codepoints.filterNot { it.value == '-'.code }) + return arbitrary { rs -> + val flags = Array(3) { rs.random.nextBoolean() } + if (!flags[0]) { + flags[2] = true + } + + val prefix = if (flags[0]) invalidString.bind() else "" + val mid = if (flags[1]) "-" else "" + val suffix = if (flags[2]) invalidString.bind() else "" + + "$prefix$mid$suffix" + } + } + + fun Arb.Companion.unparseableDate(): Arb { + val validNumber = intWithEvenNumDigitsDistribution(0..Int.MAX_VALUE) + val unparseableNumber = unparseableNumber() + val unparseableDash = unparseableDash() + val booleanArray = booleanArray(Arb.constant(5), Arb.boolean()) + return arbitrary(edgecases = listOf("", "-", "--", "---")) { rs -> + val invalidCharFlags = booleanArray.bind() + if (invalidCharFlags.count { it } == 0) { + invalidCharFlags[rs.random.nextInt(invalidCharFlags.indices)] = true + } + + val year = if (invalidCharFlags[0]) unparseableNumber.bind() else validNumber.bind() + val dash1 = if (invalidCharFlags[1]) unparseableDash.bind() else "-" + val month = if (invalidCharFlags[2]) unparseableNumber.bind() else validNumber.bind() + val dash2 = if (invalidCharFlags[3]) unparseableDash.bind() else "-" + val day = if (invalidCharFlags[4]) unparseableNumber.bind() else validNumber.bind() + + "$year$dash1$month$dash2$day" + } + } + } +} diff --git a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt index 6d94b9ca477..6bc0c45d3bc 100644 --- a/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt +++ b/firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/serializers/LocalDateSerializerUnitTest.kt @@ -15,6 +15,12 @@ */ @file:OptIn(ExperimentalKotest::class) +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED TO JavaTimeLocalDateSerializerUnitTest.kt and +// KotlinxDatetimeLocalDateSerializerUnitTest.kt AND ADAPTED TO TEST THE +// CORRESPONDING IMPLEMENTATIONS OF LocalDate. ANY CHANGES MADE TO THIS FILE +// MUST ALSO BE PORTED TO THOSE OTHER FILES, IF APPROPRIATE. +//////////////////////////////////////////////////////////////////////////////// package com.google.firebase.dataconnect.serializers import com.google.firebase.dataconnect.LocalDate @@ -56,6 +62,12 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.encoding.Decoder import org.junit.Test +//////////////////////////////////////////////////////////////////////////////// +// THIS FILE WAS COPIED TO JavaTimeLocalDateSerializerUnitTest.kt and +// KotlinxDatetimeLocalDateSerializerUnitTest.kt AND ADAPTED TO TEST THE +// CORRESPONDING IMPLEMENTATIONS OF LocalDate. ANY CHANGES MADE TO THIS FILE +// MUST ALSO BE PORTED TO THOSE OTHER FILES, IF APPROPRIATE. +//////////////////////////////////////////////////////////////////////////////// class LocalDateSerializerUnitTest { @Test diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/JavaTime.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/JavaTime.kt index 7e1fc14c8fc..9981e7e08ba 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/JavaTime.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/JavaTime.kt @@ -16,11 +16,21 @@ package com.google.firebase.dataconnect.testutil -import com.google.firebase.Timestamp -import com.google.firebase.dataconnect.LocalDate -import org.threeten.bp.Instant +import android.annotation.SuppressLint -fun Instant.toTimestamp(): Timestamp = Timestamp(epochSecond, nano) +fun org.threeten.bp.Instant.toTimestamp(): com.google.firebase.Timestamp = + com.google.firebase.Timestamp(epochSecond, nano) -fun LocalDate.toTheeTenAbpJavaLocalDate(): org.threeten.bp.LocalDate = - org.threeten.bp.LocalDate.of(year, month, day) +fun com.google.firebase.dataconnect.LocalDate.toTheeTenAbpJavaLocalDate(): + org.threeten.bp.LocalDate = org.threeten.bp.LocalDate.of(year, month, day) + +@SuppressLint("NewApi") +fun java.time.LocalDate.toTheeTenAbpJavaLocalDate(): org.threeten.bp.LocalDate { + val threeTenBpMonth = org.threeten.bp.Month.of(monthValue) + return org.threeten.bp.LocalDate.of(year, threeTenBpMonth, dayOfMonth) +} + +fun kotlinx.datetime.LocalDate.toTheeTenAbpJavaLocalDate(): org.threeten.bp.LocalDate { + val threeTenBpMonth = org.threeten.bp.Month.of(monthNumber) + return org.threeten.bp.LocalDate.of(year, threeTenBpMonth, dayOfMonth) +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/dates.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/dates.kt index 1535b12a167..662062e4069 100644 --- a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/dates.kt +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/dates.kt @@ -23,6 +23,7 @@ import com.google.firebase.dataconnect.testutil.NullableReference import com.google.firebase.dataconnect.testutil.dayRangeInYear import com.google.firebase.dataconnect.testutil.property.arbitrary.DateEdgeCases.MAX_YEAR import com.google.firebase.dataconnect.testutil.property.arbitrary.DateEdgeCases.MIN_YEAR +import com.google.firebase.dataconnect.toDataConnectLocalDate import io.kotest.property.Arb import io.kotest.property.RandomSource import io.kotest.property.Sample @@ -99,7 +100,17 @@ private class DateTestDataArb : Arb() { data class DateTestData( val date: LocalDate, val string: String, -) +) { + constructor( + date: java.time.LocalDate, + string: String + ) : this(date.toDataConnectLocalDate(), string) + + constructor( + date: kotlinx.datetime.LocalDate, + string: String + ) : this(date.toDataConnectLocalDate(), string) +} @Suppress("MemberVisibilityCanBePrivate") object DateEdgeCases { @@ -149,11 +160,24 @@ data class ThreeDateTestDatas( fun idsMatchingSelected(getter: (ItemNumber) -> UUID): List = idsMatching(selected?.date, getter) - fun idsMatching(localDate: LocalDate?, getter: (ItemNumber) -> UUID): List { + fun idsMatching( + localDate: LocalDate?, + getter: (ItemNumber) -> UUID, + ): List { val ids = listOf(getter(ItemNumber.ONE), getter(ItemNumber.TWO), getter(ItemNumber.THREE)) return ids.filterIndexed { index, _ -> all[index]?.date == localDate } } + fun idsMatching( + localDate: java.time.LocalDate?, + getter: (ItemNumber) -> UUID, + ): List = idsMatching(localDate?.toDataConnectLocalDate(), getter) + + fun idsMatching( + localDate: kotlinx.datetime.LocalDate?, + getter: (ItemNumber) -> UUID, + ): List = idsMatching(localDate?.toDataConnectLocalDate(), getter) + enum class ItemNumber { ONE, TWO,