diff --git a/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ExprValues.kt b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ExprValues.kt new file mode 100644 index 00000000000..33079e0b279 --- /dev/null +++ b/firebase-dataconnect/androidTestutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/ExprValues.kt @@ -0,0 +1,55 @@ +/* + * 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.testutil + +import com.google.firebase.dataconnect.FirebaseDataConnect +import com.google.firebase.dataconnect.LocalDate +import com.google.firebase.dataconnect.serializers.UUIDSerializer +import io.kotest.matchers.nulls.shouldNotBeNull +import java.util.UUID +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer + +suspend fun FirebaseDataConnect.requestTimeAsDate(): LocalDate = requestTime().requestTimeAsDate + +private suspend fun FirebaseDataConnect.requestTime(): ExprValuesQueryData.Item { + val insertMutationRef = + mutation("ExprValues_Insert", Unit, serializer(), serializer()) + val insertResult = insertMutationRef.execute() + + val queryVariables = ExprValuesQueryVariables(insertResult.data.key) + val getByKeyQueryRef = + query("ExprValues_GetByKey", queryVariables, serializer(), serializer()) + val queryResults = getByKeyQueryRef.execute() + + return queryResults.data.item.shouldNotBeNull() +} + +@Serializable +private data class ExprValuesQueryData(val item: Item?) { + @Serializable + data class Item( + val requestTimeAsDate: LocalDate, + ) +} + +@Serializable private data class ExprValuesQueryVariables(val key: ExprValuesKey) + +@Serializable private data class ExprValuesInsertData(val key: ExprValuesKey) + +@Serializable +private data class ExprValuesKey(@Serializable(with = UUIDSerializer::class) val id: UUID) diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt index eda897697f6..dca905d755d 100644 --- a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/DateScalarIntegrationTest.kt @@ -27,8 +27,8 @@ import com.google.firebase.dataconnect.testutil.dateFromYearMonthDayUTC import com.google.firebase.dataconnect.testutil.executeWithEmptyVariables import com.google.firebase.dataconnect.testutil.property.arbitrary.EdgeCases import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.date -import com.google.firebase.dataconnect.testutil.property.arbitrary.dateOffDayBoundary +import com.google.firebase.dataconnect.testutil.property.arbitrary.dateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.toJavaUtilDate import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.withClue @@ -37,7 +37,6 @@ import io.kotest.property.Arb import io.kotest.property.arbitrary.int import io.kotest.property.arbitrary.next import io.kotest.property.checkAll -import java.util.Date import kotlinx.coroutines.test.runTest import kotlinx.serialization.Serializable import kotlinx.serialization.serializer @@ -47,8 +46,8 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nonNullDate_insert_NormalCases() = runTest { - checkAll(20, Arb.dataConnect.date()) { - val key = connector.insertNonNullDate.execute(it.date).data.key + checkAll(20, Arb.dataConnect.dateTestData()) { + val key = connector.insertNonNullDate.execute(it.toJavaUtilDate()).data.key assertNonNullDateByKeyEquals(key, it.string) } } @@ -56,8 +55,8 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nonNullDate_insert_EdgeCases() = runTest { assertSoftly { - EdgeCases.dates.all.forEach { - val key = connector.insertNonNullDate.execute(it.date).data.key + EdgeCases.dates.all().forEach { + val key = connector.insertNonNullDate.execute(it.toJavaUtilDate()).data.key assertNonNullDateByKeyEquals(key, it.string) } } @@ -76,10 +75,9 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nonNullDate_insert_ShouldIgnoreTime() = runTest { - checkAll(20, Arb.dataConnect.dateOffDayBoundary()) { - val key = connector.insertNonNullDate.execute(it.date).data.key - assertNonNullDateByKeyEquals(key, it.string) - } + val date = "2024-03-26T19:48:00.144Z" + val key = connector.insertNonNullDate.executeWithStringVariables(date).data.key + assertNonNullDateByKeyEquals(key, dateFromYearMonthDayUTC(2024, 3, 26)) } @Test @@ -96,7 +94,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { GetNonNullDatesWithDefaultsByKeyQuery.Data.NonNullDatesWithDefaults( valueWithVariableDefault = dateFromYearMonthDayUTC(6904, 11, 30), valueWithSchemaDefault = dateFromYearMonthDayUTC(2112, 1, 31), - epoch = EdgeCases.dates.zero.date, + epoch = EdgeCases.dates.epoch.toJavaUtilDate(), requestTime1 = expectedRequestTime, requestTime2 = expectedRequestTime, ) @@ -135,24 +133,26 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nonNullDate_update_NormalCases() = runTest { - checkAll(20, Arb.dataConnect.date(), Arb.dataConnect.date()) { date1, date2 -> - val key = connector.insertNonNullDate.execute(date1.date).data.key - connector.updateNonNullDate.execute(key) { value = date2.date } + checkAll(20, Arb.dataConnect.dateTestData(), Arb.dataConnect.dateTestData()) { date1, date2 -> + val key = connector.insertNonNullDate.execute(date1.toJavaUtilDate()).data.key + connector.updateNonNullDate.execute(key) { value = date2.toJavaUtilDate() } assertNonNullDateByKeyEquals(key, date2.string) } } @Test fun nonNullDate_update_EdgeCases() = runTest { - val edgeCases = EdgeCases.dates.all - val dates1 = edgeCases + List(edgeCases.size) { Arb.dataConnect.date().next(rs) } + edgeCases - val dates2 = List(edgeCases.size) { Arb.dataConnect.date().next(rs) } + edgeCases + edgeCases + val edgeCases = EdgeCases.dates.all() + val dates1 = + edgeCases + List(edgeCases.size) { Arb.dataConnect.dateTestData().next(rs) } + edgeCases + val dates2 = + List(edgeCases.size) { Arb.dataConnect.dateTestData().next(rs) } + edgeCases + edgeCases assertSoftly { for ((date1, date2) in dates1.zip(dates2)) { withClue("date1=${date1.string} date2=${date2.string}") { - val key = connector.insertNonNullDate.execute(date1.date).data.key - connector.updateNonNullDate.execute(key) { value = date2.date } + val key = connector.insertNonNullDate.execute(date1.toJavaUtilDate()).data.key + connector.updateNonNullDate.execute(key) { value = date2.toJavaUtilDate() } assertNonNullDateByKeyEquals(key, date2.string) } } @@ -161,26 +161,26 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nonNullDate_update_DateVariableOmitted() = runTest { - val date = Arb.dataConnect.date().next(rs) - val key = connector.insertNonNullDate.execute(date.date).data.key + val date = Arb.dataConnect.dateTestData().next(rs) + val key = connector.insertNonNullDate.execute(date.toJavaUtilDate()).data.key connector.updateNonNullDate.execute(key) {} - assertNonNullDateByKeyEquals(key, date.date) + assertNonNullDateByKeyEquals(key, date.toJavaUtilDate()) } @Test fun nullableDate_insert_NormalCases() = runTest { - checkAll(20, Arb.dataConnect.date()) { - val key = connector.insertNullableDate.execute { value = it.date }.data.key + checkAll(20, Arb.dataConnect.dateTestData()) { + val key = connector.insertNullableDate.execute { value = it.toJavaUtilDate() }.data.key assertNullableDateByKeyEquals(key, it.string) } } @Test fun nullableDate_insert_EdgeCases() = runTest { - val edgeCases = EdgeCases.dates.all + listOf(null) + val edgeCases = EdgeCases.dates.all() + listOf(null) assertSoftly { edgeCases.forEach { - val key = connector.insertNullableDate.execute { value = it?.date }.data.key + val key = connector.insertNullableDate.execute { value = it?.toJavaUtilDate() }.data.key if (it === null) { assertNullableDateByKeyHasNullInnerValue(key) } else { @@ -209,10 +209,9 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nullableDate_insert_ShouldIgnoreTime() = runTest { - checkAll(20, Arb.dataConnect.dateOffDayBoundary()) { - val key = connector.insertNullableDate.execute { value = it.date }.data.key - assertNullableDateByKeyEquals(key, it.string) - } + val date = "2024-03-26T19:48:00.144Z" + val key = connector.insertNullableDate.executeWithStringVariables(date).data.key + assertNullableDateByKeyEquals(key, dateFromYearMonthDayUTC(2024, 3, 26)) } @Test @@ -245,7 +244,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { GetNullableDatesWithDefaultsByKeyQuery.Data.NullableDatesWithDefaults( valueWithVariableDefault = dateFromYearMonthDayUTC(8113, 2, 9), valueWithSchemaDefault = dateFromYearMonthDayUTC(1921, 12, 2), - epoch = EdgeCases.dates.zero.date, + epoch = EdgeCases.dates.epoch.toJavaUtilDate(), requestTime1 = expectedRequestTime, requestTime2 = expectedRequestTime, ) @@ -254,24 +253,26 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nullableDate_update_NormalCases() = runTest { - checkAll(20, Arb.dataConnect.date(), Arb.dataConnect.date()) { date1, date2 -> - val key = connector.insertNullableDate.execute { value = date1.date }.data.key - connector.updateNullableDate.execute(key) { value = date2.date } + checkAll(20, Arb.dataConnect.dateTestData(), Arb.dataConnect.dateTestData()) { date1, date2 -> + val key = connector.insertNullableDate.execute { value = date1.toJavaUtilDate() }.data.key + connector.updateNullableDate.execute(key) { value = date2.toJavaUtilDate() } assertNullableDateByKeyEquals(key, date2.string) } } @Test fun nullableDate_update_EdgeCases() = runTest { - val edgeCases = EdgeCases.dates.all - val dates1 = edgeCases + List(edgeCases.size) { Arb.dataConnect.date().next(rs) } + edgeCases - val dates2 = List(edgeCases.size) { Arb.dataConnect.date().next(rs) } + edgeCases + edgeCases + val edgeCases = EdgeCases.dates.all() + val dates1 = + edgeCases + List(edgeCases.size) { Arb.dataConnect.dateTestData().next(rs) } + edgeCases + val dates2 = + List(edgeCases.size) { Arb.dataConnect.dateTestData().next(rs) } + edgeCases + edgeCases assertSoftly { for ((date1, date2) in dates1.zip(dates2)) { withClue("date1=${date1.string} date2=${date2.string}") { - val key = connector.insertNullableDate.execute { value = date1.date }.data.key - connector.updateNullableDate.execute(key) { value = date2.date } + val key = connector.insertNullableDate.execute { value = date1.toJavaUtilDate() }.data.key + connector.updateNullableDate.execute(key) { value = date2.toJavaUtilDate() } assertNullableDateByKeyEquals(key, date2.string) } } @@ -280,7 +281,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nullableDate_update_UpdateNonNullValueToNull() = runTest { - val date = Arb.dataConnect.date().next(rs).date + val date = Arb.dataConnect.dateTestData().next(rs).toJavaUtilDate() val key = connector.insertNullableDate.execute { value = date }.data.key connector.updateNullableDate.execute(key) { value = null } assertNullableDateByKeyHasNullInnerValue(key) @@ -288,7 +289,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nullableDate_update_UpdateNullValueToNonNull() = runTest { - val date = Arb.dataConnect.date().next(rs).date + val date = Arb.dataConnect.dateTestData().next(rs).toJavaUtilDate() val key = connector.insertNullableDate.execute { value = null }.data.key connector.updateNullableDate.execute(key) { value = date } assertNullableDateByKeyEquals(key, date) @@ -296,7 +297,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun nullableDate_update_DateVariableOmitted() = runTest { - val date = Arb.dataConnect.date().next(rs).date + val date = Arb.dataConnect.dateTestData().next(rs).toJavaUtilDate() val key = connector.insertNullableDate.execute { value = date }.data.key connector.updateNullableDate.execute(key) {} assertNullableDateByKeyEquals(key, date) @@ -310,7 +311,7 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { queryResult.data shouldBe GetDateByKeyQueryStringData(expected) } - private suspend fun assertNonNullDateByKeyEquals(key: NonNullDateKey, expected: Date) { + private suspend fun assertNonNullDateByKeyEquals(key: NonNullDateKey, expected: java.util.Date) { val queryResult = connector.getNonNullDateByKey.execute(key) queryResult.data shouldBe GetNonNullDateByKeyQuery.Data(GetNonNullDateByKeyQuery.Data.Value(expected)) @@ -333,7 +334,10 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { queryResult.data shouldBe GetDateByKeyQueryStringData(expected) } - private suspend fun assertNullableDateByKeyEquals(key: NullableDateKey, expected: Date) { + private suspend fun assertNullableDateByKeyEquals( + key: NullableDateKey, + expected: java.util.Date + ) { val queryResult = connector.getNullableDateByKey.execute(key) queryResult.data shouldBe GetNullableDateByKeyQuery.Data(GetNullableDateByKeyQuery.Data.Value(expected)) @@ -341,8 +345,8 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { /** * A `Data` type that can be used in place of [GetNonNullDateByKeyQuery.Data] that types the value - * as a [String] instead of a [Date], allowing verification of the data sent over the wire without - * possible confounding from date deserialization. + * as a [String] instead of a [java.util.Date], allowing verification of the data sent over the + * wire without possible confounding from date deserialization. */ @Serializable private data class GetDateByKeyQueryStringData(val value: DateStringValue?) { @@ -353,15 +357,15 @@ class DateScalarIntegrationTest : DemoConnectorIntegrationTestBase() { /** * A `Variables` type that can be used in place of [InsertNonNullDateMutation.Variables] that - * types the value as a [String] instead of a [Date], allowing verification of the data sent over - * the wire without possible confounding from date serialization. + * types the value as a [String] instead of a [java.util.Date], allowing verification of the data + * sent over the wire without possible confounding from date serialization. */ @Serializable private data class InsertDateStringVariables(val value: String?) /** * A `Variables` type that can be used in place of [InsertNonNullDateMutation.Variables] that - * types the value as a [Int] instead of a [Date], allowing verification that the server fails - * with an expected error (rather than crashing, for example). + * types the value as a [Int] instead of a [java.util.Date], allowing verification that the server + * fails with an expected error (rather than crashing, for example). */ @Serializable private data class InsertDateIntVariables(val value: Int) diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt index 62e1610434e..0ec62809cf0 100644 --- a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/KeyVariablesIntegrationTest.kt @@ -18,7 +18,8 @@ package com.google.firebase.dataconnect.connectors.demo import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorIntegrationTestBase import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.date +import com.google.firebase.dataconnect.testutil.property.arbitrary.dateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.toJavaUtilDate import com.google.firebase.dataconnect.testutil.randomTimestamp import com.google.firebase.dataconnect.testutil.withMicrosecondPrecision import io.kotest.matchers.shouldBe @@ -85,7 +86,7 @@ class KeyVariablesIntegrationTest : DemoConnectorIntegrationTestBase() { @Test fun primaryKeyIsDate() = runTest { - val id = Arb.dataConnect.date().next(rs).date + val id = Arb.dataConnect.dateTestData().next(rs).toJavaUtilDate() val value = Arb.dataConnect.string().next(rs) val key = connector.insertPrimaryKeyIsDate.execute(foo = id, value = value).data.key diff --git a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt index d5bcf5176d8..0125ae2cb15 100644 --- a/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt +++ b/firebase-dataconnect/connectors/src/androidTest/kotlin/com/google/firebase/dataconnect/connectors/demo/ListVariablesAndDataIntegrationTest.kt @@ -21,7 +21,8 @@ import com.google.firebase.dataconnect.connectors.demo.testutil.DemoConnectorInt import com.google.firebase.dataconnect.testutil.property.arbitrary.DataConnectArb import com.google.firebase.dataconnect.testutil.property.arbitrary.EdgeCases import com.google.firebase.dataconnect.testutil.property.arbitrary.dataConnect -import com.google.firebase.dataconnect.testutil.property.arbitrary.date +import com.google.firebase.dataconnect.testutil.property.arbitrary.dateTestData +import com.google.firebase.dataconnect.testutil.property.arbitrary.toJavaUtilDate import com.google.firebase.dataconnect.testutil.withMicrosecondPrecision import io.kotest.common.ExperimentalKotest import io.kotest.matchers.shouldBe @@ -523,7 +524,7 @@ class ListVariablesAndDataIntegrationTest : DemoConnectorIntegrationTestBase() { booleans = EdgeCases.booleans, uuids = EdgeCases.uuids, int64s = EdgeCases.int64s, - dates = EdgeCases.dates.all.map { it.date }, + dates = EdgeCases.dates.all().map { it.toJavaUtilDate() }, timestamps = EdgeCases.javaTime.instants.all.map { it.timestamp }, ) } @@ -545,7 +546,8 @@ class ListVariablesAndDataIntegrationTest : DemoConnectorIntegrationTestBase() { booleans: Arb> = Arb.list(Arb.boolean(), 1..100), uuids: Arb> = Arb.list(Arb.uuid(), 1..100), int64s: Arb> = Arb.list(Arb.long(), 1..100), - dates: Arb> = Arb.list(Arb.dataConnect.date().map { it.date }, 1..100), + dates: Arb> = + Arb.list(Arb.dataConnect.dateTestData().map { it.toJavaUtilDate() }, 1..100), timestamps: Arb> = Arb.list(Arb.dataConnect.javaTime.instantTestCase().map { it.timestamp }, 1..100), ): Arb = arbitrary { diff --git a/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql b/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql index c9033f88aa1..2f22ed6210a 100644 --- a/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql +++ b/firebase-dataconnect/emulator/dataconnect/connector/demo/demo_ops.gql @@ -1154,6 +1154,158 @@ query GetNullableListsByKey($key: NullableLists_Key!) @auth(level: PUBLIC) { } } +############################################################################### +# Operations for table: DateNonNullable +############################################################################### + +mutation DateNonNullable_Insert($value: Date!) @auth(level: PUBLIC) { + key: dateNonNullable_insert(data: { value: $value }) +} + +mutation DateNonNullable_Insert3($tag: String!, $value1: Date!, $value2: Date!, $value3: Date!) @auth(level: PUBLIC) { + key1: dateNonNullable_insert(data: { value: $value1, tag: $tag }) + key2: dateNonNullable_insert(data: { value: $value2, tag: $tag }) + key3: dateNonNullable_insert(data: { value: $value3, tag: $tag }) +} + +query DateNonNullable_GetByKey($key: DateNonNullable_Key!) @auth(level: PUBLIC) { + item: dateNonNullable(key: $key) { value } +} + +query DateNonNullable_GetAllByTagAndValue($tag: String!, $value: Date!) @auth(level: PUBLIC) { + items: dateNonNullables( + limit: 5, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +query DateNonNullable_GetAllByTagAndMaybeValue($tag: String!, $value: Date) @auth(level: PUBLIC) { + items: dateNonNullables( + limit: 5, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +query DateNonNullable_GetAllByTagAndDefaultValue($tag: String!, $value: Date! = "2692-05-21") @auth(level: PUBLIC) { + items: dateNonNullables( + limit: 5, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +mutation DateNonNullable_UpdateByKey($key: DateNonNullable_Key!, $value: Date) @auth(level: PUBLIC) { + dateNonNullable_update(key: $key, data: { value: $value }) +} + +mutation DateNonNullable_UpdateByTagAndValue($tag: String!, $value: Date, $newValue: Date) @auth(level: PUBLIC) { + dateNonNullable_updateMany( + where: { value: { eq: $value }, tag: { eq: $tag } }, + data: { value: $newValue } + ) +} + +mutation DateNonNullable_DeleteByTagAndValue($tag: String!, $value: Date) @auth(level: PUBLIC) { + dateNonNullable_deleteMany( + where: { value: { eq: $value }, tag: { eq: $tag } } + ) +} + +############################################################################### +# Operations for table: DateNonNullableWithDefaults +############################################################################### + +mutation DateNonNullableWithDefaults_Insert($value: Date! = "6904-11-30") @auth(level: PUBLIC) { + key: dateNonNullableWithDefaults_insert(data: { + valueWithVariableDefault: $value + }) +} + +query DateNonNullableWithDefaults_GetByKey($key: DateNonNullableWithDefaults_Key!) @auth(level: PUBLIC) { + item: dateNonNullableWithDefaults(key: $key) { + valueWithVariableDefault + valueWithSchemaDefault + epoch + requestTime1 + requestTime2 + } +} + +############################################################################### +# Operations for table: DateNullable +############################################################################### + +mutation DateNullable_Insert($value: Date) @auth(level: PUBLIC) { + key: dateNullable_insert(data: { value: $value }) +} + +mutation DateNullable_Insert3($tag: String!, $value1: Date, $value2: Date, $value3: Date) @auth(level: PUBLIC) { + key1: dateNullable_insert(data: { value: $value1, tag: $tag }) + key2: dateNullable_insert(data: { value: $value2, tag: $tag }) + key3: dateNullable_insert(data: { value: $value3, tag: $tag }) +} + +query DateNullable_GetByKey($key: DateNullable_Key!) @auth(level: PUBLIC) { + item: dateNullable(key: $key) { value } +} + +query DateNullable_GetAllByTagAndValue($tag: String!, $value: Date) @auth(level: PUBLIC) { + items: dateNullables( + limit: 5, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +query DateNullable_GetAllByTagAndDefaultValue($tag: String!, $value: Date! = "1771-10-28") @auth(level: PUBLIC) { + items: dateNullables( + limit: 5, + where: { value: { eq: $value }, tag: { eq: $tag } }, + ) { id } +} + +mutation DateNullable_UpdateByKey($key: DateNullable_Key!, $value: Date) @auth(level: PUBLIC) { + dateNullable_update(key: $key, data: { value: $value }) +} + +mutation DateNullable_UpdateByTagAndValue($tag: String!, $value: Date, $newValue: Date) @auth(level: PUBLIC) { + dateNullable_updateMany( + where: { value: { eq: $value }, tag: { eq: $tag } }, + data: { value: $newValue } + ) +} + +mutation DateNullable_DeleteByTagAndValue($tag: String!, $value: Date) @auth(level: PUBLIC) { + dateNullable_deleteMany( + where: { value: { eq: $value }, tag: { eq: $tag } } + ) +} + +############################################################################### +# Operations for table: DateNullableWithDefaults +############################################################################### + +mutation DateNullableWithDefaults_Insert( + $valueWithVariableDefault: Date = "8113-02-09", + $valueWithVariableNullDefault: Date = null, +) @auth(level: PUBLIC) { + key: dateNullableWithDefaults_insert(data: { + valueWithVariableDefault: $valueWithVariableDefault, + valueWithVariableNullDefault: $valueWithVariableNullDefault, + }) +} + +query DateNullableWithDefaults_GetByKey($key: DateNullableWithDefaults_Key!) @auth(level: PUBLIC) { + item: dateNullableWithDefaults(key: $key) { + valueWithVariableDefault + valueWithVariableNullDefault + valueWithSchemaDefault + valueWithSchemaNullDefault + valueWithNoDefault + epoch + requestTime1 + requestTime2 + } +} + mutation InsertNonNullDate($value: Date!) @auth(level: PUBLIC) { nonNullDate_insert(data: { value: $value }) } @@ -1465,3 +1617,19 @@ query OrderDirectionTestGetAllByTag( where: { tag: { eq: $tag } }, ) { id } } + +############################################################################### +# Operations for table: type ExprValues +############################################################################### + +mutation ExprValues_Insert @auth(level: PUBLIC) { + key: exprValues_insert(data: { + requestTimeAsDate_expr: "request.time" + }) +} + +query ExprValues_GetByKey($key: ExprValues_Key!) @auth(level: PUBLIC) { + item: exprValues(key: $key) { + requestTimeAsDate + } +} diff --git a/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql b/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql index 2833f158408..49ba107a134 100644 --- a/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql +++ b/firebase-dataconnect/emulator/dataconnect/schema/demo_schema.gql @@ -12,6 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +type ExprValues @table { + requestTimeAsDate: Date! +} + type Foo @table @index(fields: ["bar"]) { id: String! bar: String @@ -254,6 +258,35 @@ type NullableLists @table { timestamps: [Timestamp!] } +type DateNonNullable @table @index(fields: ["tag"]) { + value: Date! + tag: String +} + +type DateNullable @table @index(fields: ["tag"]) { + value: Date + tag: String +} + +type DateNonNullableWithDefaults @table { + valueWithVariableDefault: Date! + valueWithSchemaDefault: Date! @default(value: "2112-01-31") + epoch: Date! @default(sql: "'epoch'::date") + requestTime1: Date! @default(expr: "request.time") + requestTime2: Date! @default(expr: "request.time") +} + +type DateNullableWithDefaults @table { + valueWithVariableDefault: Date + valueWithVariableNullDefault: Date + valueWithSchemaDefault: Date @default(value: "1921-12-02") + valueWithSchemaNullDefault: Date @default(value: null) + valueWithNoDefault: Date + epoch: Date @default(sql: "'epoch'::date") + requestTime1: Date @default(expr: "request.time") + requestTime2: Date @default(expr: "request.time") +} + type NonNullDate @table { value: Date! } diff --git a/firebase-dataconnect/firebase-dataconnect.gradle.kts b/firebase-dataconnect/firebase-dataconnect.gradle.kts index 58e98be8b2a..87ffe572916 100644 --- a/firebase-dataconnect/firebase-dataconnect.gradle.kts +++ b/firebase-dataconnect/firebase-dataconnect.gradle.kts @@ -142,6 +142,7 @@ dependencies { androidTestImplementation(libs.kotlinx.datetime) androidTestImplementation(libs.mockk) androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.testonly.three.ten.abp) androidTestImplementation(libs.turbine) } 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 new file mode 100644 index 00000000000..05055ba4e39 --- /dev/null +++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/LocalDateIntegrationTest.kt @@ -0,0 +1,695 @@ +/* + * 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) + +package com.google.firebase.dataconnect + +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.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 + +class LocalDateIntegrationTest : 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()) { 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) + 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().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) + 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 LocalDate(6904, 11, 30) + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe LocalDate(2112, 1, 31) + } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date } + 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(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) + 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 LocalDate(8113, 2, 9) + } + withClue("valueWithVariableNullDefault") { + item.valueWithVariableNullDefault.shouldBeNull() + } + withClue("valueWithSchemaDefault") { + item.valueWithSchemaDefault shouldBe LocalDate(1921, 12, 2) + } + withClue("valueWithSchemaNullDefault") { item.valueWithSchemaNullDefault.shouldBeNull() } + withClue("valueWithNoDefault") { item.valueWithNoDefault.shouldBeNull() } + withClue("epoch") { item.epoch shouldBe EdgeCases.dates.epoch.date } + 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(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) + 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: LocalDate?) + + @Serializable private data class InsertStringVariables(val value: String) + + @Serializable + private data class Insert3Variables( + val tag: String, + val value1: LocalDate?, + val value2: LocalDate?, + val value3: LocalDate?, + ) + + @Serializable private data class TagVariables(val tag: String) + + @Serializable private data class TagAndValueVariables(val tag: String, val value: 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: LocalDate?) + } + + @Serializable + private data class GetInsertedWithDefaultsByKeyQueryData(val item: Item?) { + @Serializable + data class Item( + val valueWithVariableDefault: LocalDate, + val valueWithVariableNullDefault: LocalDate?, + val valueWithSchemaDefault: LocalDate, + val valueWithSchemaNullDefault: LocalDate?, + val valueWithNoDefault: LocalDate?, + val epoch: LocalDate?, + val requestTime1: LocalDate?, + val requestTime2: 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: 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, + value2 = testDatas.testData2?.date, + value3 = testDatas.testData3?.date + ) + + suspend fun insert3( + tag: String, + value1: LocalDate?, + value2: LocalDate?, + value3: 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: 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: LocalDate?, + ): List = idsMatching(result.data, localDate) + + fun ThreeDateTestDatas.idsMatching( + data: ThreeKeysData, + localDate: 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/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 762ae83d3b0..7e1fc14c8fc 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 @@ -17,6 +17,10 @@ package com.google.firebase.dataconnect.testutil import com.google.firebase.Timestamp +import com.google.firebase.dataconnect.LocalDate import org.threeten.bp.Instant fun Instant.toTimestamp(): Timestamp = Timestamp(epochSecond, nano) + +fun LocalDate.toTheeTenAbpJavaLocalDate(): org.threeten.bp.LocalDate = + org.threeten.bp.LocalDate.of(year, month, day) diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/NullableReference.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/NullableReference.kt new file mode 100644 index 00000000000..26cae8e4d06 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/NullableReference.kt @@ -0,0 +1,27 @@ +/* + * 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.testutil + +/** + * A class that simply wraps a reference to another object, which may be null. This class can be + * useful for use in the case where the meaning of `null` is overloaded, such as + * [io.kotest.property.Arb.edgecase] and [kotlinx.coroutines.flow.MutableStateFlow.compareAndSet] + */ +class NullableReference(val ref: T? = null) { + override fun equals(other: Any?) = (other is NullableReference<*>) && other.ref == ref + override fun hashCode() = ref?.hashCode() ?: 0 + override fun toString() = ref?.toString() ?: "null" +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/InvalidDateScalarStringArb.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/InvalidDateScalarStringArb.kt new file mode 100644 index 00000000000..673463f99e7 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/InvalidDateScalarStringArb.kt @@ -0,0 +1,442 @@ +/* + * 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:Suppress("UnusedReceiverParameter") + +package com.google.firebase.dataconnect.testutil.property.arbitrary + +import com.google.firebase.dataconnect.LocalDate +import com.google.firebase.dataconnect.testutil.dayRangeInYear +import io.kotest.property.Arb +import io.kotest.property.RandomSource +import io.kotest.property.Sample +import io.kotest.property.arbitrary.Codepoint +import io.kotest.property.arbitrary.arbitrary +import io.kotest.property.arbitrary.az +import io.kotest.property.arbitrary.choose +import io.kotest.property.arbitrary.constant +import io.kotest.property.arbitrary.enum +import io.kotest.property.arbitrary.filter +import io.kotest.property.arbitrary.filterNot +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.merge +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.string +import io.kotest.property.asSample +import kotlin.random.nextInt +import org.threeten.bp.Month +import org.threeten.bp.Year + +fun DataConnectArb.invalidDateScalarString(): Arb = + InvalidDateScalarStringArb() + +private class InvalidDateScalarStringArb( + private val validLocalDateArb: Arb = DataConnectArb.localDate(), +) : Arb() { + private val nonLeapYearArb: Arb = Arb.threeTenBp.year().filterNot { it.isLeap } + private val leapYearArb: Arb = Arb.threeTenBp.year().filter { it.isLeap } + private val invalidConfigArb: Arb = Arb.invalidConfig() + private val edgeCaseFlagsArb: Arb = Arb.edgeCaseFlags() + private val nonDigitStringArb: Arb = + Arb.string(1..5, Codepoint.az().merge(Arb.constant(Codepoint('-'.code)))).filterNot { + it == "-" + } + private val edgeCaseProbability = 0.15 + private val invalidChoiceArb: Arb = + Arb.choose( + 3 to 0, + 5 to 1, + 1 to 2, + 1 to 3, + ) + + override fun sample(rs: RandomSource): Sample { + val validLocalDate: LocalDate = validLocalDateArb.sample(rs).value + val invalidConfig: InvalidConfig = invalidConfigArb.sample(rs).value + val validMonth = Month.of(validLocalDate.month) + val validYear = Year.of(validLocalDate.year) + return InvalidDateScalarString( + year = yearString(rs, invalidConfig.year, validYear), + dash1 = dashString(rs, invalidConfig.dash1), + month = monthString(rs, invalidConfig.month, validMonth), + dash2 = dashString(rs, invalidConfig.dash2), + day = dayString(rs, invalidConfig.day, validLocalDate.day, validMonth, validYear), + debugDescription = "invalidConfig=$invalidConfig, validLocalDate=$validLocalDate", + ) + .asSample() + } + + override fun edgecase(rs: RandomSource): InvalidDateScalarString { + val validLocalDate: LocalDate = validLocalDateArb.sample(rs).value + val invalidChoice = invalidChoiceArb.next(rs) + val invalidConfig: InvalidConfig = + invalidConfigArb.sample(rs).value.let { + when (invalidChoice) { + 0 -> it + 1 -> it.copyWithOnlyOneInvalidProperty(rs) + 2 -> InvalidConfig.allEmpty + 3 -> + return InvalidDateScalarString.ofYearMonthDay( + nonLeapYearArb.next(rs), + Month.FEBRUARY, + 29, + debugDescription = "Feb29 of a non-leap year" + ) + 4 -> + return InvalidDateScalarString.ofYearMonthDay( + leapYearArb.next(rs), + Month.FEBRUARY, + 30, + debugDescription = "Feb30 of a leap year" + ) + else -> throw Exception("invalid invalidChoice: $invalidChoice (error code btb7w6h236)") + } + } + val edgeCaseFlags: EdgeCaseFlags = edgeCaseFlagsArb.sample(rs).value + val validMonth = Month.of(validLocalDate.month) + val validYear = Year.of(validLocalDate.year) + return InvalidDateScalarString( + year = + if (edgeCaseFlags.year) yearStringEdgeCase(rs, invalidConfig.year, validYear) + else yearString(rs, invalidConfig.year, validYear), + dash1 = + if (edgeCaseFlags.dash1) dashStringEdgeCase(rs, invalidConfig.dash1) + else dashString(rs, invalidConfig.dash1), + month = + if (edgeCaseFlags.month) monthStringEdgeCase(rs, invalidConfig.month, validMonth) + else monthString(rs, invalidConfig.month, validMonth), + dash2 = + if (edgeCaseFlags.dash2) dashStringEdgeCase(rs, invalidConfig.dash2) + else dashString(rs, invalidConfig.dash2), + day = + if (!edgeCaseFlags.day) + dayString(rs, invalidConfig.day, validLocalDate.day, validMonth, validYear) + else + dayStringEdgeCase( + rs, + invalidConfig.day, + validLocalDate.day, + validMonth, + validYear, + ), + debugDescription = + "invalidConfig=$invalidConfig, " + + "invalidChoice=$invalidChoice, " + + "edgeCaseFlags=$edgeCaseFlags, " + + "validLocalDate=$validLocalDate", + ) + } + + private fun yearString(rs: RandomSource, reason: InvalidIntReason, validValue: Year): String = + intString(rs, reason, validValue.value, validYearRange) + + private fun yearStringEdgeCase( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Year + ): String = intStringEdgeCase(rs, reason, validValue.value, validYearRange) + + private fun monthString(rs: RandomSource, reason: InvalidIntReason, validValue: Month): String = + intString(rs, reason, validValue.value, validMonthRange) + + private fun monthStringEdgeCase( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Month + ): String = intStringEdgeCase(rs, reason, validValue.value, validMonthRange) + + private fun dayString( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Int, + month: Month, + year: Year, + ): String = intString(rs, reason, validValue, month.dayRangeInYear(year)) + + private fun dayStringEdgeCase( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Int, + month: Month, + year: Year, + ): String = intStringEdgeCase(rs, reason, validValue, month.dayRangeInYear(year)) + + private fun dashString(rs: RandomSource, reason: InvalidDashReason): String = + when (reason) { + InvalidDashReason.Valid -> "-" + InvalidDashReason.NonDashesOnly -> rs.nextNonDigitString() + InvalidDashReason.Prefix -> rs.nextNonDigitString() + "-" + InvalidDashReason.Suffix -> "-" + rs.nextNonDigitString() + InvalidDashReason.PrefixAndSuffix -> rs.nextNonDigitString() + "-" + rs.nextNonDigitString() + InvalidDashReason.MoreThanOneDash -> "-".repeat(rs.random.nextInt(2..5)) + InvalidDashReason.Empty -> "" + } + + private fun dashStringEdgeCase(rs: RandomSource, reason: InvalidDashReason): String = + when (reason) { + InvalidDashReason.Valid -> "-" + InvalidDashReason.NonDashesOnly -> rs.nextNonDigitChar().toString() + InvalidDashReason.Prefix -> rs.nextNonDigitChar() + "-" + InvalidDashReason.Suffix -> "-" + rs.nextNonDigitChar() + InvalidDashReason.PrefixAndSuffix -> { + val prefixIsEdgeCase = rs.nextIsEdgeCase() + val suffixIsEdgeCase = !prefixIsEdgeCase || rs.nextIsEdgeCase() + val prefix = if (prefixIsEdgeCase) rs.nextNonDigitChar() else rs.nextNonDigitString() + val suffix = if (suffixIsEdgeCase) rs.nextNonDigitChar() else rs.nextNonDigitString() + "$prefix-$suffix" + } + InvalidDashReason.MoreThanOneDash -> "-".repeat(rs.random.nextInt(2..5)) + InvalidDashReason.Empty -> "" + } + + private fun intString( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Int, + validRange: IntRange + ): String { + val validLength = validRange.last.toString().length + return when (reason) { + InvalidIntReason.Valid -> "$validValue".padStart(validLength, '0') + InvalidIntReason.NonDigitsOnly -> rs.nextNonDigitString() + InvalidIntReason.NonDigitPrefix -> rs.nextNonDigitString() + "$validValue" + InvalidIntReason.NonDigitSuffix -> "$validValue" + rs.nextNonDigitString() + InvalidIntReason.NonDigitPrefixAndSuffix -> + rs.nextNonDigitString() + "$validValue" + rs.nextNonDigitString() + InvalidIntReason.Padding -> { + val validValueStr = "$validValue" + val paddingLengths = + (1..20).toList().filterNot { it == validLength || it < validValueStr.length } + val paddingLength = paddingLengths.random(rs.random) + validValueStr.padStart(paddingLength, '0') + } + InvalidIntReason.TooLarge -> "${rs.random.nextInt(validRange.last + 1..Int.MAX_VALUE)}" + InvalidIntReason.TooSmall -> "${rs.random.nextInt(Int.MIN_VALUE until validRange.first)}" + InvalidIntReason.Empty -> "" + } + } + + private fun intStringEdgeCase( + rs: RandomSource, + reason: InvalidIntReason, + validValue: Int, + validRange: IntRange + ): String { + val validLength = validRange.last.toString().length + return when (reason) { + InvalidIntReason.Valid -> "$validValue".padStart(validLength, '0') + InvalidIntReason.NonDigitsOnly -> rs.nextNonDigitChar().toString() + InvalidIntReason.NonDigitPrefix -> rs.nextNonDigitChar() + "$validValue" + InvalidIntReason.NonDigitSuffix -> "$validValue" + rs.nextNonDigitChar() + InvalidIntReason.NonDigitPrefixAndSuffix -> { + val prefixIsEdgeCase = rs.nextIsEdgeCase() + val suffixIsEdgeCase = !prefixIsEdgeCase || rs.nextIsEdgeCase() + val prefix = if (prefixIsEdgeCase) rs.nextNonDigitChar() else rs.nextNonDigitString() + val suffix = if (suffixIsEdgeCase) rs.nextNonDigitChar() else rs.nextNonDigitString() + "$prefix$validValue$suffix" + } + InvalidIntReason.Padding -> { + val validValueStr = "$validValue" + val paddingLengths = + (1..20).toList().filterNot { it == validLength || it < validValueStr.length } + val paddingLength = + if (rs.random.nextBoolean()) paddingLengths.first() else paddingLengths.last() + validValueStr.padStart(paddingLength, '0') + } + InvalidIntReason.TooLarge -> "${validRange.last + 1}" + InvalidIntReason.TooSmall -> "${validRange.first - 1}" + InvalidIntReason.Empty -> "" + } + } + + private fun RandomSource.nextNonDigitString(): String = nonDigitStringArb.next(this) + + private fun RandomSource.nextNonDigitChar(): Char = + nonDigitStringArb.map { it.first() }.filterNot { it == '-' }.next(this) + + private fun RandomSource.nextIsEdgeCase(): Boolean = random.nextDouble() < edgeCaseProbability + + private fun Arb.Companion.edgeCaseFlags(): Arb = arbitrary { rs -> + EdgeCaseFlags( + year = rs.nextIsEdgeCase(), + dash1 = rs.nextIsEdgeCase(), + month = rs.nextIsEdgeCase(), + dash2 = rs.nextIsEdgeCase(), + day = rs.nextIsEdgeCase(), + ) + } + + private enum class InvalidIntReason { + Valid, + NonDigitsOnly, + NonDigitPrefix, + NonDigitSuffix, + NonDigitPrefixAndSuffix, + Padding, + TooLarge, + TooSmall, + Empty, + } + + private enum class InvalidDashReason { + Valid, + NonDashesOnly, + Prefix, + Suffix, + PrefixAndSuffix, + MoreThanOneDash, + Empty, + } + + private data class EdgeCaseFlags( + val year: Boolean, + val dash1: Boolean, + val month: Boolean, + val dash2: Boolean, + val day: Boolean, + ) + + private data class InvalidConfig( + val year: InvalidIntReason, + val dash1: InvalidDashReason, + val month: InvalidIntReason, + val dash2: InvalidDashReason, + val day: InvalidIntReason, + ) { + val isValid: Boolean = + year == InvalidIntReason.Valid && + dash1 == InvalidDashReason.Valid && + month == InvalidIntReason.Valid && + dash2 == InvalidDashReason.Valid && + day == InvalidIntReason.Valid + + private fun invalidPropertyIndexes(): List = + List(5) { + val isValid = + when (it) { + 0 -> year != InvalidIntReason.Valid + 1 -> dash1 != InvalidDashReason.Valid + 2 -> month != InvalidIntReason.Valid + 3 -> dash2 != InvalidDashReason.Valid + 4 -> day != InvalidIntReason.Valid + else -> throw Exception("invalid index: $it (error code pdkcse2hsj)") + } + if (isValid) it else null + } + .filterNotNull() + .also { + require(it.isNotEmpty()) { + "no invalid properties found in $this (error code 44te27r37h)" + } + } + + fun copyWithOnlyOneInvalidProperty(rs: RandomSource): InvalidConfig { + val invalidProperties = invalidPropertyIndexes() + return when (val invalidIndex = invalidProperties.random(rs.random)) { + 0 -> allValid.copy(year = year) + 1 -> allValid.copy(dash1 = dash1) + 2 -> allValid.copy(month = month) + 3 -> allValid.copy(dash2 = dash2) + 4 -> allValid.copy(day = day) + else -> throw Exception("invalid index: $invalidIndex (error code 8bheyzyq7x)") + } + } + + companion object { + val allValid: InvalidConfig + get() = + InvalidConfig( + year = InvalidIntReason.Valid, + dash1 = InvalidDashReason.Valid, + month = InvalidIntReason.Valid, + dash2 = InvalidDashReason.Valid, + day = InvalidIntReason.Valid, + ) + + val allEmpty: InvalidConfig + get() = + InvalidConfig( + year = InvalidIntReason.Empty, + dash1 = InvalidDashReason.Empty, + month = InvalidIntReason.Empty, + dash2 = InvalidDashReason.Empty, + day = InvalidIntReason.Empty, + ) + } + } + + private companion object { + val validYearRange: IntRange = 0..9999 + val validMonthRange: IntRange = 1..12 + + fun Arb.Companion.invalidConfig(): Arb { + val invalidIntReasonArb: Arb = + Arb.choose( + 3 to Arb.constant(InvalidIntReason.Valid), + 1 to Arb.enum().filterNot { it == InvalidIntReason.Valid }, + ) + val invalidDashReasonArb: Arb = + Arb.choose( + 3 to Arb.constant(InvalidDashReason.Valid), + 1 to Arb.enum().filterNot { it == InvalidDashReason.Valid }, + ) + return arbitrary { + InvalidConfig( + year = invalidIntReasonArb.bind(), + dash1 = invalidDashReasonArb.bind(), + month = invalidIntReasonArb.bind(), + dash2 = invalidDashReasonArb.bind(), + day = invalidIntReasonArb.bind(), + ) + } + .filterNot { it.isValid } + } + } +} + +data class InvalidDateScalarString( + val year: String, + val dash1: String, + val month: String, + val dash2: String, + val day: String, + val debugDescription: String, +) { + fun toDateScalarString() = "$year$dash1$month$dash2$day" + + override fun toString(): String = + "InvalidDateScalarString(" + + "year=$year, " + + "dash1=$dash1, " + + "month=$month, " + + "dash2=$dash2, " + + "day=$day, " + + "toDateScalarString()=${toDateScalarString()}, " + + "debugDescription=$debugDescription" + + ")" + + companion object { + fun ofYearMonthDay(year: Year, month: Month, day: Int, debugDescription: String) = + InvalidDateScalarString( + year = year.value.toString().padStart(4, '0'), + month = month.value.toString().padStart(2, '0'), + day = day.toString().padStart(2, '0'), + dash1 = "-", + dash2 = "-", + debugDescription = debugDescription, + ) + } +} diff --git a/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/NullableReferenceArb.kt b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/NullableReferenceArb.kt new file mode 100644 index 00000000000..ca3e9dbba53 --- /dev/null +++ b/firebase-dataconnect/testutil/src/main/kotlin/com/google/firebase/dataconnect/testutil/property/arbitrary/NullableReferenceArb.kt @@ -0,0 +1,97 @@ +/* + * 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.testutil.property.arbitrary + +import com.google.firebase.dataconnect.testutil.NullableReference +import io.kotest.property.Arb +import io.kotest.property.RTree +import io.kotest.property.RandomSource +import io.kotest.property.Sample +import io.kotest.property.arbitrary.orNull +import io.kotest.property.asSample +import io.kotest.property.map + +/** + * Returns an [Arb] that produces the same samples and edge cases as `this` except that the values + * are wrapped in [NullableReference]. Samples are `NullableReference(null)` with a probability of + * the given [nullProbability], which must be between `0.0` and `1.0`, inclusive; otherwise, a + * [NullableReference] of a sample from `this` is used. + * + * This is functionally equivalent to the standard [orNull] function, except that it works around + * the problem that [Arb.edgecase] returning `null` indicates that _no_ edge cases are supported, + * rather than `null` itself being an edge case. + * + * See https://github.com/kotest/kotest/issues/4029 for details. + */ +fun Arb.orNullableReference(nullProbability: Double): Arb> = + NullableReferenceArb(this, nullProbability) + +private class NullableReferenceArb( + private val arb: Arb, + private val nullProbability: Double, +) : Arb>() { + init { + require(nullProbability in 0.0..1.0) { + "invalid nullProbability: $nullProbability (must be between 0.0 and 1.0, inclusive)" + } + } + + override fun sample(rs: RandomSource): Sample> { + if (rs.isNextNull()) { + return NullableReference(null).asSample() + } + + val baseSample = arb.sample(rs) + val wrappedShrinks = baseSample.shrinks.map { NullableReference(it) } + + val shrinks = + if (nullProbability == 0.0 || baseSample.value === null) { + wrappedShrinks + } else { + // This logic was adapted from the implementation of io.kotest.property.arbitrary.orNull. + RTree( + wrappedShrinks.value, + lazy { listOf(RTree({ NullableReference(null) })) + wrappedShrinks.children.value } + ) + } + + return Sample(NullableReference(baseSample.value), shrinks) + } + + override fun edgecase(rs: RandomSource): NullableReference? { + if (rs.isNextNull()) { + return NullableReference(null) + } + + val edgeCase = arb.edgecase(rs) + + // Return null if, and only if, the underlying Arb does not support edge cases _and_ the + // nullProbability is 0.0, because in this specific case there are no edge cases to return. + if (edgeCase === null && nullProbability == 0.0) { + return null + } + + return NullableReference(edgeCase) + } + + private fun RandomSource.isNextNull(): Boolean = + when (nullProbability) { + 0.0 -> false + 1.0 -> true + else -> random.nextDouble() <= nullProbability + } +} 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 7c97a0dcc1c..6959ccbd50b 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 @@ -18,63 +18,92 @@ package com.google.firebase.dataconnect.testutil.property.arbitrary +import com.google.firebase.dataconnect.LocalDate +import com.google.firebase.dataconnect.testutil.NullableReference import com.google.firebase.dataconnect.testutil.dateFromYearMonthDayUTC +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 io.kotest.property.Arb -import io.kotest.property.arbitrary.arbitrary -import io.kotest.property.arbitrary.filterNot +import io.kotest.property.RandomSource +import io.kotest.property.Sample import io.kotest.property.arbitrary.int -import io.kotest.property.arbitrary.long -import java.util.Date -import java.util.GregorianCalendar -import java.util.TimeZone - -data class DateTestData( - val date: Date, - val string: String, - val year: Int, - val month: Int, - val day: Int -) { - fun withMillisOffset(millisOffset: Long): DateTestData = - copy(date = Date(date.time + millisOffset)) -} +import io.kotest.property.arbitrary.map +import io.kotest.property.arbitrary.next +import io.kotest.property.asSample +import java.util.UUID +import kotlin.random.nextInt +import org.threeten.bp.Month +import org.threeten.bp.Year + +fun DataConnectArb.dateTestData(): Arb = DateTestDataArb() + +fun DataConnectArb.localDate(): Arb = dateTestData().map { it.date } + +fun DataConnectArb.threeNonNullDatesTestData( + dateTestData: Arb = dateTestData() +): Arb = ThreeDateTestDatasArb(dateTestData.map { NullableReference(it) }) + +fun DataConnectArb.threePossiblyNullDatesTestData( + dateTestData: Arb> = + dateTestData().orNullableReference(nullProbability = 0.333) +): Arb = ThreeDateTestDatasArb(dateTestData) + +/** An [Arb] that produces [DateTestData] objects that are accepted by Firebase Data Connect. */ +private class DateTestDataArb : Arb() { + + private val yearArb: Arb = Arb.intWithEvenNumDigitsDistribution(yearRange).map(Year::of) + private val monthArb: Arb = Arb.intWithEvenNumDigitsDistribution(monthRange).map(Month::of) + + override fun sample(rs: RandomSource): Sample { + val year = yearArb.next(rs) + val month = monthArb.next(rs) + val dayRange = month.dayRangeInYear(year) + val day = dayRange.random(rs.random) + return toDateTestData(year = year, month = month, day = day).asSample() + } -fun DataConnectArb.date(): Arb = - arbitrary(edgecases = DateEdgeCases.all) { - val year = year().bind() - val month = month().bind() - val day = day(month).bind() + override fun edgecase(rs: RandomSource): DateTestData { + val year = rs.edgeCaseFrom(yearArb) + val month = rs.edgeCaseFrom(monthArb) + val day = rs.edgeCaseFrom(Arb.int(month.dayRangeInYear(year))) + return toDateTestData(year = year, month = month, day = day) + } - val date = dateFromYearMonthDayUTC(year, month, day) + private fun toDateTestData(year: Year, month: Month, day: Int): DateTestData { + val localDate = LocalDate(year = year.value, month = month.value, day = day) val yearStr = "$year" - val monthStr = "$month".padStart(2, '0') + val monthStr = "${month.value}".padStart(2, '0') val dayStr = "$day".padStart(2, '0') val string = "$yearStr-$monthStr-$dayStr" - DateTestData(date, string, year = year, month = month, day = day) + return DateTestData(localDate, string) } -fun DataConnectArb.dateOffDayBoundary(): Arb = - arbitrary(edgecases = DateEdgeCases.offDayBoundary) { - // Skip dates with the maximum year, as adding non-zero milliseconds will result in the year - // 10,000, which is invalid. - val dateAndStrings = date().filterNot { it.string.contains("9999") } - // Don't add more than 86_400_000L, the number of milliseconds per day, to the date. - val millisOffsets = Arb.long(0L until 86_400_000L) - - val dateAndString = dateAndStrings.bind() - val millisOffset = millisOffsets.bind() - val dateOffDayBoundary = Date(dateAndString.date.time + millisOffset) - - DateTestData( - dateOffDayBoundary, - dateAndString.string, - year = dateAndString.year, - month = dateAndString.month, - day = dateAndString.day, - ) + companion object { + val yearRange: IntRange = MIN_YEAR..MAX_YEAR + val monthRange: IntRange = 1..12 + + private fun RandomSource.edgeCaseFrom(arb: Arb): T { + if (random.nextBoolean()) { + val edgeCase = arb.edgecase(this) + if (edgeCase !== null) { + return edgeCase + } + } + return arb.next(this) + } } +} + +data class DateTestData( + val date: LocalDate, + val string: String, +) + +fun DateTestData.toJavaUtilDate(): java.util.Date = + dateFromYearMonthDayUTC(year = date.year, month = date.month, day = date.day) @Suppress("MemberVisibilityCanBePrivate") object DateEdgeCases { @@ -84,66 +113,82 @@ object DateEdgeCases { const val MAX_YEAR = 9999 val min: DateTestData - get() = - DateTestData( - date = dateFromYearMonthDayUTC(MIN_YEAR, 1, 1), - string = "$MIN_YEAR-01-01", - year = MIN_YEAR, - month = 1, - day = 1, - ) - + get() = DateTestData(LocalDate(MIN_YEAR, 1, 1), "$MIN_YEAR-01-01") val max: DateTestData - get() = - DateTestData( - date = dateFromYearMonthDayUTC(MAX_YEAR, 12, 31), - string = "$MAX_YEAR-12-31", - year = MAX_YEAR, - month = 12, - day = 31, - ) - - val zero: DateTestData - get() = - DateTestData( - date = GregorianCalendar(TimeZone.getTimeZone("UTC")).apply { timeInMillis = 0 }.time, - string = "1970-01-01", - year = 1970, - month = 1, - day = 1, - ) - - val all: List = listOf(min, max, zero) - - val offDayBoundary: List = - listOf( - min.withMillisOffset(1), - max.withMillisOffset(1), - zero.withMillisOffset(1), - ) + get() = DateTestData(LocalDate(MAX_YEAR, 12, 31), "$MAX_YEAR-12-31") + val epoch: DateTestData + get() = DateTestData(LocalDate(1970, 1, 1), "1970-01-01") + + fun all(): List = listOf(min, max, epoch) } -private fun maxDayForMonth(month: Int): Int { - return when (month) { - 1 -> 31 - 2 -> 28 - 3 -> 31 - 4 -> 30 - 5 -> 31 - 6 -> 30 - 7 -> 31 - 8 -> 31 - 9 -> 30 - 10 -> 31 - 11 -> 30 - 12 -> 31 - else -> - throw IllegalArgumentException("invalid month: $month (must be between 1 and 12, inclusive)") +data class ThreeDateTestDatas( + val testData1: DateTestData?, + val testData2: DateTestData?, + val testData3: DateTestData?, + private val index: Int, +) { + init { + require(index in 0..2) { "invalid index: $index (error code shfwcz4j4w)" } } -} -private fun year(): Arb = Arb.int(DateEdgeCases.MIN_YEAR..DateEdgeCases.MAX_YEAR) + val all: List + get() = listOf(testData1, testData2, testData3) + + val selected: DateTestData? = + when (index) { + 0 -> testData1 + 1 -> testData2 + 2 -> testData3 + else -> throw Exception("internal error: unknown index: $index") + } -private fun month(): Arb = Arb.int(1..12) + fun idsMatchingSelected(getter: (ItemNumber) -> UUID): List = + idsMatching(selected?.date, getter) -private fun day(month: Int): Arb = Arb.int(1..maxDayForMonth(month)) + 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 } + } + + enum class ItemNumber { + ONE, + TWO, + THREE, + } +} + +private class ThreeDateTestDatasArb( + private val dateTestData: Arb> +) : Arb() { + override fun sample(rs: RandomSource): Sample = + rs.nextThreeLocalDates { dateTestData.next(rs) }.asSample() + + override fun edgecase(rs: RandomSource): ThreeDateTestDatas { + val result: ThreeDateTestDatas = + rs.nextThreeLocalDates { + if (rs.random.nextBoolean()) { + dateTestData.edgecase(rs)!! + } else { + dateTestData.next(rs) + } + } + + return when (val case = rs.random.nextInt(0..4)) { + 0 -> result + 1 -> result.copy(testData2 = result.testData1) + 2 -> result.copy(testData3 = result.testData1) + 3 -> result.copy(testData3 = result.testData2) + 4 -> result.copy(testData2 = result.testData1, testData3 = result.testData1) + else -> throw Exception("should never get here: case=$case (error code yzqq7kw3eh)") + } + } + + private fun RandomSource.nextThreeLocalDates( + nextDateTestData: () -> NullableReference + ): ThreeDateTestDatas { + val dates = List(3) { nextDateTestData().ref } + val index = random.nextInt(dates.indices) + return ThreeDateTestDatas(dates[0], dates[1], dates[2], index) + } +}