diff --git a/README.md b/README.md index 03628c78c..d486b504e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ db.collection("cities").document("LA").set(City.serializer(), city, encodeDefaul ``` The `encodeDefaults` parameter is optional and defaults to `true`, set this to false to omit writing optional properties if they are equal to theirs default values. +Using [@EncodeDefault](https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-core/kotlinx.serialization/-encode-default/) on properties is a recommended way to locally override the behavior set with `encodeDefaults`. You can also omit the serializer but this is discouraged due to a [current limitation on Kotlin/JS and Kotlin/Native](https://github.com/Kotlin/kotlinx.serialization/issues/1116#issuecomment-704342452) @@ -110,6 +111,21 @@ data class Post( ) ``` +In addition `firebase-firestore` provides [GeoPoint] and [DocumentReference] classes which allow persisting +geo points and document references in a native way: + +```kotlin +@Serializable +data class PointOfInterest( + val reference: DocumentReference, + val location: GeoPoint +) +val document = PointOfInterest( + reference = Firebase.firestore.collection("foo").document("bar"), + location = GeoPoint(51.939, 4.506) +) +``` +

Polymorphic serialization (sealed classes)

This sdk will handle polymorphic serialization automatically if you have a sealed class and its children marked as `Serializable`. It will include a `type` property that will be used to discriminate which child class is the serialized. diff --git a/build.gradle.kts b/build.gradle.kts index cbcc2716d..8e19e4f55 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,7 +65,28 @@ subprojects { onlyIf { !project.gradle.startParameter.taskNames.contains("publishToMavenLocal") } } + val skipPublishing = project.name == "test-utils" // skip publishing for test utils + tasks { + withType { + testLogging { + showExceptions = true + exceptionFormat = TestExceptionFormat.FULL + showStandardStreams = true + showCauses = true + showStackTraces = true + events = setOf( + TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT, + TestLogEvent.STANDARD_ERROR + ) + } + } + + if (skipPublishing) return@tasks val updateVersion by registering(Exec::class) { commandLine("npm", "--allow-same-version", "--prefix", projectDir, "version", "${project.property("${project.name}.version")}") @@ -136,24 +157,6 @@ subprojects { commandLine("npm", "publish") } } - - withType { - testLogging { - showExceptions = true - exceptionFormat = TestExceptionFormat.FULL - showStandardStreams = true - showCauses = true - showStackTraces = true - events = setOf( - TestLogEvent.STARTED, - TestLogEvent.FAILED, - TestLogEvent.PASSED, - TestLogEvent.SKIPPED, - TestLogEvent.STANDARD_OUT, - TestLogEvent.STANDARD_ERROR - ) - } - } } afterEvaluate { @@ -181,8 +184,10 @@ subprojects { } } - apply(plugin="maven-publish") - apply(plugin="signing") + if (skipPublishing) return@subprojects + + apply(plugin = "maven-publish") + apply(plugin = "signing") configure { @@ -235,6 +240,7 @@ subprojects { } } } + } } diff --git a/firebase-common/build.gradle.kts b/firebase-common/build.gradle.kts index 93df71e17..b0d27b29a 100644 --- a/firebase-common/build.gradle.kts +++ b/firebase-common/build.gradle.kts @@ -90,6 +90,12 @@ kotlin { } } + val commonTest by getting { + dependencies { + implementation(project(":test-utils")) + } + } + val androidMain by getting { dependencies { api("com.google.firebase:firebase-common") diff --git a/firebase-common/src/commonMain/kotlin/dev/gitlive/firebase/serializers.kt b/firebase-common/src/commonMain/kotlin/dev/gitlive/firebase/serializers.kt index f6a3995ab..e9ab9003f 100644 --- a/firebase-common/src/commonMain/kotlin/dev/gitlive/firebase/serializers.kt +++ b/firebase-common/src/commonMain/kotlin/dev/gitlive/firebase/serializers.kt @@ -108,7 +108,7 @@ class FirebaseListSerializer : KSerializer> { * A special case of serializer for values natively supported by Firebase and * don't require an additional encoding/decoding. */ -abstract class SpecialValueSerializer( +class SpecialValueSerializer( serialName: String, private val toNativeValue: (T) -> Any?, private val fromNativeValue: (Any?) -> T diff --git a/firebase-common/src/commonTest/kotlin/dev/gitlive/firebase/EncodersTest.kt b/firebase-common/src/commonTest/kotlin/dev/gitlive/firebase/EncodersTest.kt index 6af1cf0e8..1c33b1437 100644 --- a/firebase-common/src/commonTest/kotlin/dev/gitlive/firebase/EncodersTest.kt +++ b/firebase-common/src/commonTest/kotlin/dev/gitlive/firebase/EncodersTest.kt @@ -11,10 +11,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -expect fun nativeMapOf(vararg pairs: Pair): Any -expect fun nativeListOf(vararg elements: Any): Any -expect fun nativeAssertEquals(expected: Any?, actual: Any?): Unit - @Serializable data class TestData(val map: Map, val bool: Boolean = false, val nullableBool: Boolean? = null) diff --git a/firebase-common/src/jsMain/kotlin/dev/gitlive/firebase/externals.kt b/firebase-common/src/jsMain/kotlin/dev/gitlive/firebase/externals.kt index 04c57a497..bd346d925 100644 --- a/firebase-common/src/jsMain/kotlin/dev/gitlive/firebase/externals.kt +++ b/firebase-common/src/jsMain/kotlin/dev/gitlive/firebase/externals.kt @@ -453,6 +453,8 @@ external object firebase { fun update(field: FieldPath, value: Any?, vararg moreFieldsAndValues: Any?): Promise fun delete(): Promise fun onSnapshot(next: (snapshot: DocumentSnapshot) -> Unit, error: (error: Error) -> Unit): ()->Unit + + fun isEqual(other: DocumentReference): Boolean } open class WriteBatch { @@ -477,6 +479,8 @@ external object firebase { companion object { val documentId: FieldPath } + + fun isEqual(other: FieldPath): Boolean } abstract class FieldValue { @@ -490,6 +494,13 @@ external object firebase { fun isEqual(other: FieldValue): Boolean } + + open class GeoPoint(latitude: Double, longitude: Double) { + val latitude: Double + val longitude: Double + + fun isEqual(other: GeoPoint): Boolean + } } fun remoteConfig(app: App? = definedExternally): remoteConfig.RemoteConfig diff --git a/firebase-database/src/commonMain/kotlin/dev/gitlive/firebase/database/ServerValue.kt b/firebase-database/src/commonMain/kotlin/dev/gitlive/firebase/database/ServerValue.kt index 7c03178b5..c93ba746a 100644 --- a/firebase-database/src/commonMain/kotlin/dev/gitlive/firebase/database/ServerValue.kt +++ b/firebase-database/src/commonMain/kotlin/dev/gitlive/firebase/database/ServerValue.kt @@ -3,6 +3,7 @@ package dev.gitlive.firebase.database import dev.gitlive.firebase.FirebaseDecoder import dev.gitlive.firebase.FirebaseEncoder import dev.gitlive.firebase.SpecialValueSerializer +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -18,7 +19,7 @@ expect class ServerValue internal constructor(nativeValue: Any){ } /** Serializer for [ServerValue]. Must be used with [FirebaseEncoder]/[FirebaseDecoder].*/ -object ServerValueSerializer: SpecialValueSerializer( +object ServerValueSerializer: KSerializer by SpecialValueSerializer( serialName = "ServerValue", toNativeValue = ServerValue::nativeValue, fromNativeValue = { raw -> diff --git a/firebase-firestore/build.gradle.kts b/firebase-firestore/build.gradle.kts index 0fc7a3614..cdc9eab83 100644 --- a/firebase-firestore/build.gradle.kts +++ b/firebase-firestore/build.gradle.kts @@ -103,6 +103,12 @@ kotlin { } } + val commonTest by getting { + dependencies { + implementation(project(":test-utils")) + } + } + val androidMain by getting { dependencies { api("com.google.firebase:firebase-firestore") diff --git a/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt b/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt new file mode 100644 index 000000000..7523619f5 --- /dev/null +++ b/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt @@ -0,0 +1,19 @@ +package dev.gitlive.firebase.firestore + +import kotlinx.serialization.Serializable + +/** A class representing a platform specific Firebase GeoPoint. */ +actual typealias NativeGeoPoint = com.google.firebase.firestore.GeoPoint + +/** A class representing a Firebase GeoPoint. */ +@Serializable(with = GeoPointSerializer::class) +actual class GeoPoint internal actual constructor(internal actual val nativeValue: NativeGeoPoint) { + actual constructor(latitude: Double, longitude: Double) : this(NativeGeoPoint(latitude, longitude)) + actual val latitude: Double = nativeValue.latitude + actual val longitude: Double = nativeValue.longitude + + override fun equals(other: Any?): Boolean = + this === other || other is GeoPoint && nativeValue == other.nativeValue + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = nativeValue.toString() +} diff --git a/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt b/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt index 45ced52d1..32f317fe8 100644 --- a/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt +++ b/firebase-firestore/src/androidMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt @@ -196,8 +196,12 @@ actual class Transaction(val android: com.google.firebase.firestore.Transaction) DocumentSnapshot(android.get(documentRef.android)) } -actual class DocumentReference(val android: com.google.firebase.firestore.DocumentReference) { +/** A class representing a platform specific Firebase DocumentReference. */ +actual typealias NativeDocumentReference = com.google.firebase.firestore.DocumentReference +@Serializable(with = DocumentReferenceSerializer::class) +actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) { + val android: NativeDocumentReference by ::nativeValue actual val id: String get() = android.id @@ -270,6 +274,10 @@ actual class DocumentReference(val android: com.google.firebase.firestore.Docume } awaitClose { listener.remove() } } + override fun equals(other: Any?): Boolean = + this === other || other is DocumentReference && nativeValue == other.nativeValue + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = nativeValue.toString() } actual open class Query(open val android: com.google.firebase.firestore.Query) { @@ -432,6 +440,10 @@ actual class SnapshotMetadata(val android: com.google.firebase.firestore.Snapsho actual class FieldPath private constructor(val android: com.google.firebase.firestore.FieldPath) { actual constructor(vararg fieldNames: String) : this(com.google.firebase.firestore.FieldPath.of(*fieldNames)) actual val documentId: FieldPath get() = FieldPath(com.google.firebase.firestore.FieldPath.documentId()) + + override fun equals(other: Any?): Boolean = other is FieldPath && android == other.android + override fun hashCode(): Int = android.hashCode() + override fun toString(): String = android.toString() } /** Represents a platform specific Firebase FieldValue. */ diff --git a/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt new file mode 100644 index 000000000..3ef3a7ed3 --- /dev/null +++ b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt @@ -0,0 +1,30 @@ +package dev.gitlive.firebase.firestore + +import dev.gitlive.firebase.SpecialValueSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException + +/** A class representing a platform specific Firebase GeoPoint. */ +expect class NativeGeoPoint + +/** A class representing a Firebase GeoPoint. */ +@Serializable(with = GeoPointSerializer::class) +expect class GeoPoint internal constructor(nativeValue: NativeGeoPoint) { + constructor(latitude: Double, longitude: Double) + val latitude: Double + val longitude: Double + internal val nativeValue: NativeGeoPoint +} + +/** Serializer for [GeoPoint]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */ +object GeoPointSerializer : KSerializer by SpecialValueSerializer( + serialName = "GeoPoint", + toNativeValue = GeoPoint::nativeValue, + fromNativeValue = { value -> + when (value) { + is NativeGeoPoint -> GeoPoint(value) + else -> throw SerializationException("Cannot deserialize $value") + } + } +) diff --git a/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/Timestamp.kt b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/Timestamp.kt index 5e89e7191..8a7b02b5c 100644 --- a/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/Timestamp.kt +++ b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/Timestamp.kt @@ -1,10 +1,17 @@ package dev.gitlive.firebase.firestore +import dev.gitlive.firebase.FirebaseDecoder import dev.gitlive.firebase.FirebaseEncoder import dev.gitlive.firebase.SpecialValueSerializer import dev.gitlive.firebase.firestore.DoubleAsTimestampSerializer.serverTimestamp +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit /** A class representing a platform specific Firebase Timestamp. */ expect class NativeTimestamp @@ -31,12 +38,17 @@ expect class Timestamp internal constructor(nativeValue: NativeTimestamp): BaseT object ServerTimestamp: BaseTimestamp } -fun Timestamp.Companion.fromMilliseconds(milliseconds: Double): Timestamp = - Timestamp((milliseconds / 1000).toLong(), (milliseconds * 1000).toInt() % 1000000) -fun Timestamp.toMilliseconds(): Double = seconds * 1000 + (nanoseconds / 1000.0) +fun Timestamp.Companion.fromDuration(duration: Duration): Timestamp = + duration.toComponents { seconds, nanoseconds -> + Timestamp(seconds, nanoseconds) + } +fun Timestamp.toDuration(): Duration = seconds.seconds + nanoseconds.nanoseconds + +fun Timestamp.Companion.fromMilliseconds(milliseconds: Double): Timestamp = fromDuration(milliseconds.milliseconds) +fun Timestamp.toMilliseconds(): Double = toDuration().toDouble(DurationUnit.MILLISECONDS) -/** A serializer for [BaseTimestamp]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */ -object BaseTimestampSerializer : SpecialValueSerializer( +/** A serializer for [BaseTimestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */ +object BaseTimestampSerializer : KSerializer by SpecialValueSerializer( serialName = "Timestamp", toNativeValue = { value -> when (value) { @@ -54,8 +66,8 @@ object BaseTimestampSerializer : SpecialValueSerializer( } ) -/** A serializer for [Timestamp]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */ -object TimestampSerializer : SpecialValueSerializer( +/** A serializer for [Timestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */ +object TimestampSerializer : KSerializer by SpecialValueSerializer( serialName = "Timestamp", toNativeValue = Timestamp::nativeValue, fromNativeValue = { value -> @@ -66,8 +78,8 @@ object TimestampSerializer : SpecialValueSerializer( } ) -/** A serializer for [Timestamp.ServerTimestamp]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */ -object ServerTimestampSerializer : SpecialValueSerializer( +/** A serializer for [Timestamp.ServerTimestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */ +object ServerTimestampSerializer : KSerializer by SpecialValueSerializer( serialName = "Timestamp", toNativeValue = { FieldValue.serverTimestamp.nativeValue }, fromNativeValue = { value -> @@ -79,12 +91,12 @@ object ServerTimestampSerializer : SpecialValueSerializer( +object DoubleAsTimestampSerializer : KSerializer by SpecialValueSerializer( serialName = "Timestamp", toNativeValue = { value -> when(value) { serverTimestamp -> FieldValue.serverTimestamp.nativeValue - else -> Timestamp.fromMilliseconds(value) + else -> Timestamp.fromMilliseconds(value).nativeValue } }, fromNativeValue = { value -> diff --git a/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt index 103a9dc38..2dfe5d950 100644 --- a/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt +++ b/firebase-firestore/src/commonMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt @@ -7,6 +7,7 @@ package dev.gitlive.firebase.firestore import dev.gitlive.firebase.* import kotlinx.coroutines.flow.Flow import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationStrategy @@ -79,27 +80,35 @@ expect open class Query { internal fun _endAt(vararg fieldValues: Any): Query } -fun Query.where(field: String, equalTo: Any?) = _where(field, equalTo) -fun Query.where(path: FieldPath, equalTo: Any?) = _where(path, equalTo) -fun Query.where(field: String, equalTo: DocumentReference) = _where(field, equalTo) -fun Query.where(path: FieldPath, equalTo: DocumentReference) = _where(path, equalTo) -fun Query.where(field: String, lessThan: Any? = null, greaterThan: Any? = null, arrayContains: Any? = null) = _where(field, lessThan, greaterThan, arrayContains) -fun Query.where(path: FieldPath, lessThan: Any? = null, greaterThan: Any? = null, arrayContains: Any? = null) = _where(path, lessThan, greaterThan, arrayContains) -fun Query.where(field: String, inArray: List? = null, arrayContainsAny: List? = null) = _where(field, inArray, arrayContainsAny) -fun Query.where(path: FieldPath, inArray: List? = null, arrayContainsAny: List? = null) = _where(path, inArray, arrayContainsAny) +/** @return a native value of a wrapper or self. */ +private val Any.value get() = when (this) { + is Timestamp -> nativeValue + is GeoPoint -> nativeValue + is DocumentReference -> nativeValue + else -> this +} + +fun Query.where(field: String, equalTo: Any?) = _where(field, equalTo,value) +fun Query.where(path: FieldPath, equalTo: Any?) = _where(path, equalTo?.value) +fun Query.where(field: String, equalTo: DocumentReference) = _where(field, equalTo.value) +fun Query.where(path: FieldPath, equalTo: DocumentReference) = _where(path, equalTo.value) +fun Query.where(field: String, lessThan: Any? = null, greaterThan: Any? = null, arrayContains: Any? = null) = _where(field, lessThan?.value, greaterThan?.value, arrayContains?.value) +fun Query.where(path: FieldPath, lessThan: Any? = null, greaterThan: Any? = null, arrayContains: Any? = null) = _where(path, lessThan?.value, greaterThan?.value, arrayContains?.value) +fun Query.where(field: String, inArray: List? = null, arrayContainsAny: List? = null) = _where(field, inArray?.value, arrayContainsAny?.value) +fun Query.where(path: FieldPath, inArray: List? = null, arrayContainsAny: List? = null) = _where(path, inArray?.value, arrayContainsAny?.value) fun Query.orderBy(field: String, direction: Direction = Direction.ASCENDING) = _orderBy(field, direction) fun Query.orderBy(field: FieldPath, direction: Direction = Direction.ASCENDING) = _orderBy(field, direction) fun Query.startAfter(document: DocumentSnapshot) = _startAfter(document) -fun Query.startAfter(vararg fieldValues: Any) = _startAfter(*fieldValues) +fun Query.startAfter(vararg fieldValues: Any) = _startAfter(*(fieldValues.map { it.value }.toTypedArray())) fun Query.startAt(document: DocumentSnapshot) = _startAt(document) -fun Query.startAt(vararg fieldValues: Any) = _startAt(*fieldValues) +fun Query.startAt(vararg fieldValues: Any) = _startAt(*(fieldValues.map { it.value }.toTypedArray())) fun Query.endBefore(document: DocumentSnapshot) = _endBefore(document) -fun Query.endBefore(vararg fieldValues: Any) = _endBefore(*fieldValues) +fun Query.endBefore(vararg fieldValues: Any) = _endBefore(*(fieldValues.map { it.value }.toTypedArray())) fun Query.endAt(document: DocumentSnapshot) = _endAt(document) -fun Query.endAt(vararg fieldValues: Any) = _endAt(*fieldValues) +fun Query.endAt(vararg fieldValues: Any) = _endAt(*(fieldValues.map { it.value }.toTypedArray())) expect class WriteBatch { inline fun set(documentRef: DocumentReference, data: T, encodeDefaults: Boolean = true, merge: Boolean = false): WriteBatch @@ -120,7 +129,13 @@ expect class WriteBatch { suspend fun commit() } -expect class DocumentReference { +/** A class representing a platform specific Firebase DocumentReference. */ +expect class NativeDocumentReference + +/** A class representing a Firebase DocumentReference. */ +@Serializable(with = DocumentReferenceSerializer::class) +expect class DocumentReference internal constructor(nativeValue: NativeDocumentReference) { + internal val nativeValue: NativeDocumentReference val id: String val path: String @@ -147,6 +162,20 @@ expect class DocumentReference { suspend fun delete() } +/** + * A serializer for [DocumentReference]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. + */ +object DocumentReferenceSerializer : KSerializer by SpecialValueSerializer( + serialName = "DocumentReference", + toNativeValue = DocumentReference::nativeValue, + fromNativeValue = { value -> + when (value) { + is NativeDocumentReference -> DocumentReference(value) + else -> throw SerializationException("Cannot deserialize $value") + } + } +) + expect class CollectionReference : Query { val path: String val document: DocumentReference @@ -254,7 +283,7 @@ expect class FieldValue internal constructor(nativeValue: Any) { } /** A serializer for [FieldValue]. Must be used in conjunction with [FirebaseEncoder]. */ -object FieldValueSerializer : SpecialValueSerializer( +object FieldValueSerializer : KSerializer by SpecialValueSerializer( serialName = "FieldValue", toNativeValue = FieldValue::nativeValue, fromNativeValue = { raw -> diff --git a/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/GeoPointTests.kt b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/GeoPointTests.kt new file mode 100644 index 000000000..6ec7ee545 --- /dev/null +++ b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/GeoPointTests.kt @@ -0,0 +1,43 @@ +package dev.gitlive.firebase.firestore + +import dev.gitlive.firebase.decode +import dev.gitlive.firebase.encode +import dev.gitlive.firebase.nativeAssertEquals +import dev.gitlive.firebase.nativeMapOf +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals + +@Serializable +data class TestDataWithGeoPoint( + val uid: String, + val location: GeoPoint +) + +@Suppress("UNCHECKED_CAST") +class GeoPointTests { + + @Test + fun encodeGeoPointObject() = runTest { + val geoPoint = GeoPoint(12.3, 45.6) + val item = TestDataWithGeoPoint("123", geoPoint) + // check GeoPoint is encoded to a platform representation + nativeAssertEquals( + nativeMapOf("uid" to "123", "location" to geoPoint.nativeValue), + encode(item, shouldEncodeElementDefault = false) + ) + } + + @Test + fun decodeGeoPointObject() = runTest { + val geoPoint = GeoPoint(12.3, 45.6) + val obj = nativeMapOf( + "uid" to "123", + "location" to geoPoint.nativeValue + ) + val decoded: TestDataWithGeoPoint = decode(obj) + assertEquals("123", decoded.uid) + // check a platform GeoPoint is properly wrapped + assertEquals(geoPoint, decoded.location) + } +} diff --git a/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/TimestampTests.kt b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/TimestampTests.kt new file mode 100644 index 000000000..d310df37b --- /dev/null +++ b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/TimestampTests.kt @@ -0,0 +1,129 @@ +package dev.gitlive.firebase.firestore + +import dev.gitlive.firebase.decode +import dev.gitlive.firebase.encode +import dev.gitlive.firebase.firebaseSerializer +import dev.gitlive.firebase.nativeAssertEquals +import dev.gitlive.firebase.nativeMapOf +import kotlinx.serialization.Serializable +import kotlin.test.* +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit + +@Serializable +data class TestData( + val uid: String, + val createdAt: Timestamp, + var updatedAt: BaseTimestamp, + val deletedAt: BaseTimestamp? +) + +class TimestampTests { + @Test + fun testEquality() = runTest { + val timestamp = Timestamp(123, 456) + assertEquals(timestamp, Timestamp(123, 456)) + assertNotEquals(timestamp, Timestamp(123, 457)) + assertNotEquals(timestamp, Timestamp(124, 456)) + assertNotEquals(timestamp, Timestamp.now()) + assertEquals(Timestamp.ServerTimestamp, Timestamp.ServerTimestamp) + } + + @Test + fun encodeTimestampObject() = runTest { + val timestamp = Timestamp(123, 456) + val item = TestData("uid123", timestamp, timestamp, null) + nativeAssertEquals( + nativeMapOf( + "uid" to "uid123", + "createdAt" to timestamp.nativeValue, + "updatedAt" to timestamp.nativeValue, + "deletedAt" to null + ), + encode(item, shouldEncodeElementDefault = false) + ) + } + + @Test + fun encodeServerTimestampObject() = runTest { + val timestamp = Timestamp(123, 456) + val item = TestData("uid123", timestamp, Timestamp.ServerTimestamp, Timestamp.ServerTimestamp) + nativeAssertEquals( + nativeMapOf( + "uid" to "uid123", + "createdAt" to timestamp.nativeValue, + "updatedAt" to FieldValue.serverTimestamp.nativeValue, + "deletedAt" to FieldValue.serverTimestamp.nativeValue + ), + encode(item, shouldEncodeElementDefault = false) + ) + } + + @Test + fun decodeTimestampObject() = runTest { + val timestamp = Timestamp(123, 345) + val obj = nativeMapOf( + "uid" to "uid123", + "createdAt" to timestamp.nativeValue, + "updatedAt" to timestamp.nativeValue, + "deletedAt" to timestamp.nativeValue + ) + val decoded: TestData = decode(obj) + assertEquals("uid123", decoded.uid) + with(decoded.createdAt) { + assertEquals(timestamp, this) + assertEquals(123, seconds) + assertEquals(345, nanoseconds) + } + with(decoded.updatedAt as Timestamp) { + assertEquals(timestamp, this) + assertEquals(123, seconds) + assertEquals(345, nanoseconds) + } + with(decoded.deletedAt as Timestamp) { + assertEquals(timestamp, this) + assertEquals(123, seconds) + assertEquals(345, nanoseconds) + } + } + + @Test + fun decodeEmptyTimestampObject() = runTest { + val obj = nativeMapOf( + "uid" to "uid123", + "createdAt" to Timestamp.now().nativeValue, + "updatedAt" to Timestamp.now().nativeValue, + "deletedAt" to null + ) + val decoded: TestData = decode(obj) + assertEquals("uid123", decoded.uid) + assertNotNull(decoded.updatedAt) + assertNull(decoded.deletedAt) + } + + @Test + fun serializers() = runTest { + assertEquals(BaseTimestampSerializer, (Timestamp(0, 0) as BaseTimestamp).firebaseSerializer()) + assertEquals(BaseTimestampSerializer, (Timestamp.ServerTimestamp as BaseTimestamp).firebaseSerializer()) + assertEquals(TimestampSerializer, Timestamp(0, 0).firebaseSerializer()) + assertEquals(ServerTimestampSerializer, Timestamp.ServerTimestamp.firebaseSerializer()) + } + + @Test + fun timestampMillisecondsConversion() = runTest { + val ms = 1666170858063.0 + + val timestamp = Timestamp.fromMilliseconds(ms) + assertEquals(ms, timestamp.toMilliseconds()) + } + + @Test + fun timestampDurationConversion() = runTest { + val duration = 1666170858063.milliseconds + val (seconds, nanoseconds) = duration.toComponents { seconds, nanoseconds -> seconds to nanoseconds } + val timestamp = Timestamp.fromDuration(duration) + assertEquals(seconds, timestamp.seconds) + assertEquals(nanoseconds, timestamp.nanoseconds) + assertEquals(duration, timestamp.toDuration()) + } +} diff --git a/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/firestore.kt b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/firestore.kt index 3142f6e95..b6cc28fd7 100644 --- a/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/firestore.kt +++ b/firebase-firestore/src/commonTest/kotlin/dev/gitlive/firebase/firestore/firestore.kt @@ -459,6 +459,64 @@ class FirebaseFirestoreTest { assertNotEquals(DoubleAsTimestampSerializer.serverTimestamp, pendingWritesSnapshot.data(DoubleTimestamp.serializer(), ServerTimestampBehavior.ESTIMATE).time) } + @Test + fun testLegacyDoubleTimestampWriteNewFormatRead() = runTest { + @Serializable + data class LegacyDocument( + @Serializable(with = DoubleAsTimestampSerializer::class) + val time: Double + ) + + @Serializable + data class NewDocument( + val time: Timestamp + ) + + val doc = Firebase.firestore + .collection("testLegacyDoubleTimestampEncodeDecode") + .document("testLegacy") + + val ms = 12345678.0 + + doc.set(LegacyDocument(time = ms)) + + val fetched: NewDocument = doc.get().data() + assertEquals(ms, fetched.time.toMilliseconds()) + } + + @Test + fun testQueryByTimestamp() = runTest { + @Serializable + data class DocumentWithTimestamp( + val time: Timestamp + ) + + val collection = Firebase.firestore + .collection("testQueryByTimestamp") + + val timestamp = Timestamp.now() + + val pastTimestamp = Timestamp(timestamp.seconds - 60, 12345000) // note: iOS truncates 3 last digits of nanoseconds due to internal conversions + val futureTimestamp = Timestamp(timestamp.seconds + 60, 78910000) + + collection.add(DocumentWithTimestamp(pastTimestamp)) + collection.add(DocumentWithTimestamp(futureTimestamp)) + + val equalityQueryResult = collection.where( + path = FieldPath(DocumentWithTimestamp::time.name), + equalTo = pastTimestamp + ).get().documents.map { it.data() } + + assertEquals(listOf(DocumentWithTimestamp(pastTimestamp)), equalityQueryResult) + + val gtQueryResult = collection.where( + path = FieldPath(DocumentWithTimestamp::time.name), + greaterThan = timestamp + ).get().documents.map { it.data() } + + assertEquals(listOf(DocumentWithTimestamp(futureTimestamp)), gtQueryResult) + } + private suspend fun setupFirestoreData() { Firebase.firestore.collection("testFirestoreQuerying") .document("one") diff --git a/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt b/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt new file mode 100644 index 000000000..d4c0aa98f --- /dev/null +++ b/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt @@ -0,0 +1,21 @@ +package dev.gitlive.firebase.firestore + +import cocoapods.FirebaseFirestore.FIRGeoPoint +import kotlinx.serialization.Serializable + + +/** A class representing a platform specific Firebase GeoPoint. */ +actual typealias NativeGeoPoint = FIRGeoPoint + +/** A class representing a Firebase GeoPoint. */ +@Serializable(with = GeoPointSerializer::class) +actual class GeoPoint internal actual constructor(internal actual val nativeValue: NativeGeoPoint) { + actual constructor(latitude: Double, longitude: Double) : this(NativeGeoPoint(latitude, longitude)) + actual val latitude: Double = nativeValue.latitude + actual val longitude: Double = nativeValue.longitude + + override fun equals(other: Any?): Boolean = + this === other || other is GeoPoint && nativeValue == other.nativeValue + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = nativeValue.toString() +} diff --git a/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt b/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt index 2af5e5ed1..2f0f05534 100644 --- a/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt +++ b/firebase-firestore/src/iosMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt @@ -165,8 +165,12 @@ actual class Transaction(val ios: FIRTransaction) { } -@Suppress("UNCHECKED_CAST") -actual class DocumentReference(val ios: FIRDocumentReference) { +/** A class representing a platform specific Firebase DocumentReference. */ +actual typealias NativeDocumentReference = FIRDocumentReference + +@Serializable(with = DocumentReferenceSerializer::class) +actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) { + val ios: NativeDocumentReference by ::nativeValue actual val id: String get() = ios.documentID @@ -232,6 +236,11 @@ actual class DocumentReference(val ios: FIRDocumentReference) { } awaitClose { listener.remove() } } + + override fun equals(other: Any?): Boolean = + this === other || other is DocumentReference && nativeValue == other.nativeValue + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = nativeValue.toString() } actual open class Query(open val ios: FIRQuery) { @@ -451,6 +460,10 @@ actual class SnapshotMetadata(val ios: FIRSnapshotMetadata) { actual class FieldPath private constructor(val ios: FIRFieldPath) { actual constructor(vararg fieldNames: String) : this(FIRFieldPath(fieldNames.asList())) actual val documentId: FieldPath get() = FieldPath(FIRFieldPath.documentID()) + + override fun equals(other: Any?): Boolean = other is FieldPath && ios == other.ios + override fun hashCode(): Int = ios.hashCode() + override fun toString(): String = ios.toString() } /** A class representing a platform specific Firebase FieldValue. */ diff --git a/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt b/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt new file mode 100644 index 000000000..808704cf5 --- /dev/null +++ b/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/GeoPoint.kt @@ -0,0 +1,20 @@ +package dev.gitlive.firebase.firestore + +import dev.gitlive.firebase.* +import kotlinx.serialization.Serializable + +/** A class representing a platform specific Firebase GeoPoint. */ +actual typealias NativeGeoPoint = firebase.firestore.GeoPoint + +/** A class representing a Firebase GeoPoint. */ +@Serializable(with = GeoPointSerializer::class) +actual class GeoPoint internal actual constructor(internal actual val nativeValue: NativeGeoPoint) { + actual constructor(latitude: Double, longitude: Double) : this(NativeGeoPoint(latitude, longitude)) + actual val latitude: Double by nativeValue::latitude + actual val longitude: Double by nativeValue::longitude + + override fun equals(other: Any?): Boolean = + this === other || other is GeoPoint && nativeValue.isEqual(other.nativeValue) + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = "GeoPoint[lat=$latitude,long=$longitude]" +} diff --git a/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt b/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt index 4bf8955cd..78e69dd50 100644 --- a/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt +++ b/firebase-firestore/src/jsMain/kotlin/dev/gitlive/firebase/firestore/firestore.kt @@ -181,7 +181,12 @@ actual class Transaction(val js: firebase.firestore.Transaction) { rethrow { DocumentSnapshot(js.get(documentRef.js).await()) } } -actual class DocumentReference(val js: firebase.firestore.DocumentReference) { +/** A class representing a platform specific Firebase DocumentReference. */ +actual typealias NativeDocumentReference = firebase.firestore.DocumentReference + +@Serializable(with = DocumentReferenceSerializer::class) +actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) { + val js: NativeDocumentReference by ::nativeValue actual val id: String get() = rethrow { js.id } @@ -241,6 +246,11 @@ actual class DocumentReference(val js: firebase.firestore.DocumentReference) { ) awaitClose { unsubscribe() } } + + override fun equals(other: Any?): Boolean = + this === other || other is DocumentReference && nativeValue.isEqual(other.nativeValue) + override fun hashCode(): Int = nativeValue.hashCode() + override fun toString(): String = "DocumentReference(path=$path)" } actual open class Query(open val js: firebase.firestore.Query) { @@ -412,6 +422,10 @@ actual class FieldPath private constructor(val js: firebase.firestore.FieldPath) js("Reflect").construct(firebase.firestore.FieldPath, fieldNames).unsafeCast() }) actual val documentId: FieldPath get() = FieldPath(firebase.firestore.FieldPath.documentId) + + override fun equals(other: Any?): Boolean = other is FieldPath && js.isEqual(other.js) + override fun hashCode(): Int = js.hashCode() + override fun toString(): String = js.toString() } /** Represents a platform specific Firebase FieldValue. */ diff --git a/settings.gradle.kts b/settings.gradle.kts index 06ccce75c..b81594f9f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,8 @@ include( "firebase-functions", "firebase-installations", "firebase-perf", - "firebase-crashlytics" + "firebase-crashlytics", + "test-utils" ) //enableFeaturePreview("GRADLE_METADATA") diff --git a/test-utils/build.gradle.kts b/test-utils/build.gradle.kts new file mode 100644 index 000000000..18579f1e5 --- /dev/null +++ b/test-utils/build.gradle.kts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 GitLive Ltd. Use of this source code is governed by the Apache 2.0 license. + */ + +// this project is used only in tests to share common code. publishing is disabled in the root build.gradle.kts + +version = "0.0.1" + +plugins { + id("com.android.library") + kotlin("multiplatform") + kotlin("plugin.serialization") version "1.8.20" +} + +android { + compileSdk = property("targetSdkVersion") as Int + defaultConfig { + minSdk = property("minSdkVersion") as Int + targetSdk = property("targetSdkVersion") as Int + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + sourceSets { + getByName("main") { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + } + } + packagingOptions { + resources.pickFirsts.add("META-INF/kotlinx-serialization-core.kotlin_module") + resources.pickFirsts.add("META-INF/AL2.0") + resources.pickFirsts.add("META-INF/LGPL2.1") + } + lint { + abortOnError = false + } +} + +kotlin { + + android { + publishAllLibraryVariants() + } + + val supportIosTarget = project.property("skipIosTarget") != "true" + + if (supportIosTarget) { + ios() + iosSimulatorArm64() + } + + js { + useCommonJs() + nodejs() + browser() + } + + sourceSets { + all { + languageSettings.apply { + apiVersion = "1.8" + languageVersion = "1.8" + progressiveMode = true + } + } + + val commonMain by getting { + dependencies { + implementation(kotlin("test")) + } + } + + if (supportIosTarget) { + val iosMain by getting + val iosSimulatorArm64Main by getting + iosSimulatorArm64Main.dependsOn(iosMain) + val iosTest by sourceSets.getting + val iosSimulatorArm64Test by getting + iosSimulatorArm64Test.dependsOn(iosTest) + } + + val jsMain by getting + } +} diff --git a/test-utils/src/androidMain/AndroidManifest.xml b/test-utils/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..07001b111 --- /dev/null +++ b/test-utils/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/firebase-common/src/androidAndroidTest/kotlin/dev/gitlive/firebase/EncodersTest.kt b/test-utils/src/androidMain/kotlin/dev/gitlive/firebase/TestUtils.kt similarity index 72% rename from firebase-common/src/androidAndroidTest/kotlin/dev/gitlive/firebase/EncodersTest.kt rename to test-utils/src/androidMain/kotlin/dev/gitlive/firebase/TestUtils.kt index 231ee7b7d..90f236dee 100644 --- a/firebase-common/src/androidAndroidTest/kotlin/dev/gitlive/firebase/EncodersTest.kt +++ b/test-utils/src/androidMain/kotlin/dev/gitlive/firebase/TestUtils.kt @@ -6,4 +6,6 @@ package dev.gitlive.firebase actual fun nativeMapOf(vararg pairs: Pair): Any = mapOf(*pairs) actual fun nativeListOf(vararg elements: Any): Any = listOf(*elements) -actual fun nativeAssertEquals(expected: Any?, actual: Any?) = kotlin.test.assertEquals(expected, actual) +actual fun nativeAssertEquals(expected: Any?, actual: Any?) { + kotlin.test.assertEquals(expected, actual) +} diff --git a/test-utils/src/commonMain/kotlin/dev/gitlive/firebase/TestUtils.kt b/test-utils/src/commonMain/kotlin/dev/gitlive/firebase/TestUtils.kt new file mode 100644 index 000000000..ee6a2fe7a --- /dev/null +++ b/test-utils/src/commonMain/kotlin/dev/gitlive/firebase/TestUtils.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2020 GitLive Ltd. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.gitlive.firebase + +expect fun nativeMapOf(vararg pairs: Pair): Any +expect fun nativeListOf(vararg elements: Any): Any +expect fun nativeAssertEquals(expected: Any?, actual: Any?) diff --git a/firebase-common/src/iosTest/kotlin/dev/gitlive/firebase/EncodersTest.kt b/test-utils/src/iosMain/kotlin/dev/gitlive/firebase/TestUtils.kt similarity index 72% rename from firebase-common/src/iosTest/kotlin/dev/gitlive/firebase/EncodersTest.kt rename to test-utils/src/iosMain/kotlin/dev/gitlive/firebase/TestUtils.kt index 28b9eea5e..90f236dee 100644 --- a/firebase-common/src/iosTest/kotlin/dev/gitlive/firebase/EncodersTest.kt +++ b/test-utils/src/iosMain/kotlin/dev/gitlive/firebase/TestUtils.kt @@ -6,4 +6,6 @@ package dev.gitlive.firebase actual fun nativeMapOf(vararg pairs: Pair): Any = mapOf(*pairs) actual fun nativeListOf(vararg elements: Any): Any = listOf(*elements) -actual fun nativeAssertEquals(expected: Any?, actual: Any?) = kotlin.test.assertEquals(expected, actual) \ No newline at end of file +actual fun nativeAssertEquals(expected: Any?, actual: Any?) { + kotlin.test.assertEquals(expected, actual) +} diff --git a/firebase-common/src/jsTest/kotlin/dev/gitlive/firebase/EncodersTest.kt b/test-utils/src/jsMain/kotlin/dev/gitlive/firebase/TestUtils.kt similarity index 67% rename from firebase-common/src/jsTest/kotlin/dev/gitlive/firebase/EncodersTest.kt rename to test-utils/src/jsMain/kotlin/dev/gitlive/firebase/TestUtils.kt index 2a330f2d9..258541dd5 100644 --- a/firebase-common/src/jsTest/kotlin/dev/gitlive/firebase/EncodersTest.kt +++ b/test-utils/src/jsMain/kotlin/dev/gitlive/firebase/TestUtils.kt @@ -8,4 +8,6 @@ import kotlin.js.json actual fun nativeMapOf(vararg pairs: Pair): Any = json(*pairs) actual fun nativeListOf(vararg elements: Any): Any = elements -actual fun nativeAssertEquals(expected: Any?, actual: Any?) = kotlin.test.assertEquals(JSON.stringify(expected), JSON.stringify(actual)) \ No newline at end of file +actual fun nativeAssertEquals(expected: Any?, actual: Any?) { + kotlin.test.assertEquals(JSON.stringify(expected), JSON.stringify(actual)) +}