Skip to content

improve native serialization and timestamp fixes #371

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it helped me a lot so why not to mention it for other devs


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)

Expand All @@ -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)
)
```

<h4>Polymorphic serialization (sealed classes)</h4>

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.
Expand Down
46 changes: 26 additions & 20 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test> {
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")}")
Expand Down Expand Up @@ -136,24 +157,6 @@ subprojects {
commandLine("npm", "publish")
}
}

withType<Test> {
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 {
Expand Down Expand Up @@ -181,8 +184,10 @@ subprojects {
}
}

apply(plugin="maven-publish")
apply(plugin="signing")
if (skipPublishing) return@subprojects

apply(plugin = "maven-publish")
apply(plugin = "signing")


configure<PublishingExtension> {
Expand Down Expand Up @@ -235,6 +240,7 @@ subprojects {
}
}
}

}
}

Expand Down
6 changes: 6 additions & 0 deletions firebase-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ class FirebaseListSerializer : KSerializer<Iterable<Any?>> {
* A special case of serializer for values natively supported by Firebase and
* don't require an additional encoding/decoding.
*/
abstract class SpecialValueSerializer<T>(
class SpecialValueSerializer<T>(
serialName: String,
private val toNativeValue: (T) -> Any?,
private val fromNativeValue: (Any?) -> T
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

expect fun nativeMapOf(vararg pairs: Pair<String, Any?>): Any
expect fun nativeListOf(vararg elements: Any): Any
expect fun nativeAssertEquals(expected: Any?, actual: Any?): Unit

@Serializable
data class TestData(val map: Map<String, String>, val bool: Boolean = false, val nullableBool: Boolean? = null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ external object firebase {
fun update(field: FieldPath, value: Any?, vararg moreFieldsAndValues: Any?): Promise<Unit>
fun delete(): Promise<Unit>
fun onSnapshot(next: (snapshot: DocumentSnapshot) -> Unit, error: (error: Error) -> Unit): ()->Unit

fun isEqual(other: DocumentReference): Boolean
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proper equality checks help a lot when writing tests in the applications using this SDK

}

open class WriteBatch {
Expand All @@ -477,6 +479,8 @@ external object firebase {
companion object {
val documentId: FieldPath
}

fun isEqual(other: FieldPath): Boolean
}

abstract class FieldValue {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -18,7 +19,7 @@ expect class ServerValue internal constructor(nativeValue: Any){
}

/** Serializer for [ServerValue]. Must be used with [FirebaseEncoder]/[FirebaseDecoder].*/
object ServerValueSerializer: SpecialValueSerializer<ServerValue>(
object ServerValueSerializer: KSerializer<ServerValue> by SpecialValueSerializer(
serialName = "ServerValue",
toNativeValue = ServerValue::nativeValue,
fromNativeValue = { raw ->
Expand Down
6 changes: 6 additions & 0 deletions firebase-firestore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this approach is similar to a PR #312

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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GeoPoint> by SpecialValueSerializer(
serialName = "GeoPoint",
toNativeValue = GeoPoint::nativeValue,
fromNativeValue = { value ->
when (value) {
is NativeGeoPoint -> GeoPoint(value)
else -> throw SerializationException("Cannot deserialize $value")
}
}
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<BaseTimestamp>(
/** A serializer for [BaseTimestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */
object BaseTimestampSerializer : KSerializer<BaseTimestamp> by SpecialValueSerializer(
serialName = "Timestamp",
toNativeValue = { value ->
when (value) {
Expand All @@ -54,8 +66,8 @@ object BaseTimestampSerializer : SpecialValueSerializer<BaseTimestamp>(
}
)

/** A serializer for [Timestamp]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */
object TimestampSerializer : SpecialValueSerializer<Timestamp>(
/** A serializer for [Timestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */
object TimestampSerializer : KSerializer<Timestamp> by SpecialValueSerializer(
serialName = "Timestamp",
toNativeValue = Timestamp::nativeValue,
fromNativeValue = { value ->
Expand All @@ -66,8 +78,8 @@ object TimestampSerializer : SpecialValueSerializer<Timestamp>(
}
)

/** A serializer for [Timestamp.ServerTimestamp]. If used with [FirebaseEncoder] performs serialization using native Firebase mechanisms. */
object ServerTimestampSerializer : SpecialValueSerializer<Timestamp.ServerTimestamp>(
/** A serializer for [Timestamp.ServerTimestamp]. Must be used with [FirebaseEncoder]/[FirebaseDecoder]. */
object ServerTimestampSerializer : KSerializer<Timestamp.ServerTimestamp> by SpecialValueSerializer(
serialName = "Timestamp",
toNativeValue = { FieldValue.serverTimestamp.nativeValue },
fromNativeValue = { value ->
Expand All @@ -79,12 +91,12 @@ object ServerTimestampSerializer : SpecialValueSerializer<Timestamp.ServerTimest
)

/** A serializer for a Double field which is stored as a Timestamp. */
object DoubleAsTimestampSerializer : SpecialValueSerializer<Double>(
object DoubleAsTimestampSerializer : KSerializer<Double> by SpecialValueSerializer(
serialName = "Timestamp",
toNativeValue = { value ->
when(value) {
serverTimestamp -> FieldValue.serverTimestamp.nativeValue
else -> Timestamp.fromMilliseconds(value)
else -> Timestamp.fromMilliseconds(value).nativeValue
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a bug fix - the native value shall be extracted as in the line above

}
},
fromNativeValue = { value ->
Expand Down
Loading