-
Notifications
You must be signed in to change notification settings - Fork 161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding Serialization fixes #448
Adding Serialization fixes #448
Conversation
|
||
@Serializable | ||
data class TestData(val map: Map<String, String>, val bool: Boolean = false, val nullableBool: Boolean? = null) | ||
object TestObject { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Run these tests on the current project to see why encoding is currently broken
are their any breaking changes in here? |
There are a few where Btw, if you're worried about API changes, consider adding https://github.com/Kotlin/binary-compatibility-validator to the project. We use it for Kaluga and it makes it much easier to spot changes |
Yes we need this! |
Made changes to keep API stable |
The Java SDK has been updated to support latest firebase libs from android bom 32.7.0 and I have removed the jvmMain sources on master |
@nbransby seems publication to maven central failed: https://central.sonatype.com/artifact/dev.gitlive/firebase-java-sdk/versions |
fixed this now |
Found some more time to review... I think we should keep named arguments instead of the EncodeSettings class, revert #449 (comment) and update the examples to some of the places the new arguments are added. Having to wrap settings in EncodeSettings(...) is necessary boilerplate in the public API, people will likely only want to set 1 or 2 settings and named arguments with default values works great for that. |
Done |
sealed class EncodeDecodeSettings { | ||
|
||
/** | ||
* The [SerializersModule] to use for serialization. This allows for polymorphic serialization on runtime | ||
*/ | ||
abstract val serializersModule: SerializersModule | ||
} | ||
|
||
/** | ||
* [EncodeDecodeSettings] used when encoding an object | ||
* @property shouldEncodeElementDefault if `true` this will explicitly encode elements even if they are their default value | ||
* @param serializersModule the [SerializersModule] to use for serialization. This allows for polymorphic serialization on runtime | ||
*/ | ||
data class EncodeSettings( | ||
val shouldEncodeElementDefault: Boolean = true, | ||
override val serializersModule: SerializersModule = EmptySerializersModule(), | ||
) : EncodeDecodeSettings() | ||
|
||
/** | ||
* [EncodeDecodeSettings] used when decoding an object | ||
* @param serializersModule the [SerializersModule] to use for deserialization. This allows for polymorphic serialization on runtime | ||
*/ | ||
data class DecodeSettings( | ||
override val serializersModule: SerializersModule = EmptySerializersModule(), | ||
) : EncodeDecodeSettings() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cant these be internal now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some issues with some inline funs.
But actually, I'd like to propose that instead of named arguments we try a builder approach. E.g.:
set(value) { shouldEncodeDefaults = false }
Reason im suggesting it is because its annoying as hell right now to add new settings, since you have dozens of methods that need them specified. A builder makes it a one line addition
Uses builders instead of named arguments to improve maintainability
Im trying a builder approach instead, as suggested here #448 (comment) The advantage of this compared to named arguments is that it is much easier to maintain. |
@nbransby in case you werent aware, the builder approach is in the latest commit. Ive also tracked master again |
Should all functions that take a builder lambda be marked as inline to avoid the lambda allocation? |
Whats the point of the Async nested classes and the Deferred.convert function? |
I dont think that actually avoids anything. We can make the lambda nullable I guess
Well, to improve async behaviour. For instance, logging or grouping. E.g. suspend fun deleteSomeDocs() {
val toExecute = listOf(
firestore.collectionn("Foo").document("Bar").async.delete()
firestore.collectionn("Foo").document("BarBar").async.delete()
)
println("Started deletion")
// do some stuff in the mean time
toExectute.awaitAll()
} Deferred convert is a mapper for deferred. The Android sdk would return an AndroidDocumentReference and you'd want to return a Kotlin DocumentReference, so it should be mapped. |
It does, thats the whole point of inline functions |
As this is just a generic async utility function and unrelated to firebase we shouldn't be introducing it to our public API, you are welcome to add it to the test sources if it makes writing tests easier |
Same with this, move it to test sources if you use it in the tests |
More in line with old code and probably more stable since we're dealing with maps
Looking good, whats the purpose of the Base... and Native... classes such as NativeQuery and s BaseTransaction? @Daeda88 |
So with expect/actuals classes you cannot implement code on the common side. Even though with a lot of the code, you kind of want it (e.g., most methods need to encode/decode their values). Base solves this by introducing an abstract class that the expect/actuals classes implement. It makes code much more maintainable and also testable since they can be mocked. Native is a solution to allow for common constructors. This is useful for the abstract classes (e.g. |
I assume this is to share code internally within the library? I also assume we don't need to share additional internal state (the only state our wrapper classes should ever hold is the reference to the native instance1 in which case inheritance is probably not the right tool here, wouldn't internal extension functions be a better fit? Want to prevent polluting the public API with inheritance chains when their only purpose is internal code sharing. Footnotes
|
There's no state being stored whatsoever. This is about common code having common behaviour. To give an example with the changes: Old: // Common
expect class DocumentReference internal constructor(nativeValue: NativeDocumentReference) {
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean = true, merge: Boolean = false)
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean = true, vararg mergeFields: String)
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean = true, vararg mergeFieldPaths: FieldPath)
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean = true, merge: Boolean = false)
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean = true, vararg mergeFields: String)
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean = true, vararg mergeFieldPaths: FieldPath)
}
// Android
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) {
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, merge: Boolean) = when(merge) {
true -> android.set(encode(data, encodeDefaults)!!, SetOptions.merge())
false -> android.set(encode(data, encodeDefaults)!!)
}.await().run { Unit }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
android.set(encode(data, encodeDefaults)!!, SetOptions.mergeFields(*mergeFields))
.await().run { Unit }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
android.set(encode(data, encodeDefaults)!!, SetOptions.mergeFieldPaths(mergeFieldPaths.map { it.android }))
.await().run { Unit }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, merge: Boolean) = when(merge) {
true -> android.set(encode(strategy, data, encodeDefaults)!!, SetOptions.merge())
false -> android.set(encode(strategy, data, encodeDefaults)!!)
}.await().run { Unit }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
android.set(encode(strategy, data, encodeDefaults)!!, SetOptions.mergeFields(*mergeFields))
.await().run { Unit }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
android.set(encode(strategy, data, encodeDefaults)!!, SetOptions.mergeFieldPaths(mergeFieldPaths.map { it.android }))
.await().run { Unit }
}
// iOS
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) {
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, merge: Boolean) =
await { ios.setData(encode(data, encodeDefaults)!! as Map<Any?, *>, merge, it) }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
await { ios.setData(encode(data, encodeDefaults)!! as Map<Any?, *>, mergeFields.asList(), it) }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
await { ios.setData(encode(data, encodeDefaults)!! as Map<Any?, *>, mergeFieldPaths.map { it.ios }, it) }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, merge: Boolean) =
await { ios.setData(encode(strategy, data, encodeDefaults)!! as Map<Any?, *>, merge, it) }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
await { ios.setData(encode(strategy, data, encodeDefaults)!! as Map<Any?, *>, mergeFields.asList(), it) }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
await { ios.setData(encode(strategy, data, encodeDefaults)!! as Map<Any?, *>, mergeFieldPaths.map { it.ios }, it) }
}
// JS
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) {
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, merge: Boolean) =
rethrow { setDoc(js, encode(data, encodeDefaults)!!, json("merge" to merge)).await() }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
rethrow { setDoc(js, encode(data, encodeDefaults)!!, json("mergeFields" to mergeFields)).await() }
actual suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
rethrow { setDoc(js, encode(data, encodeDefaults)!!, json("mergeFields" to mergeFieldPaths.map { it.js }.toTypedArray())).await() }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, merge: Boolean) =
rethrow { setDoc(js, encode(strategy, data, encodeDefaults)!!, json("merge" to merge)).await() }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFields: String) =
rethrow { setDoc(js, encode(strategy, data, encodeDefaults)!!, json("mergeFields" to mergeFields)).await() }
actual suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) =
rethrow { setDoc(js, encode(strategy, data, encodeDefaults)!!, json("mergeFields" to mergeFieldPaths.map { it.js }.toTypedArray())).await() }
} All these expect/actuals funs can and should be split up into two parts:
Using abstract classes to do part 1 in common code, and then forwarding the result to 2 to do something expect/actuals reduces the footprint greatly: // Common
abstract class BaseDocumentReference {
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, merge: Boolean = false) = setEncoded(encode(data, encodeDefaults)!!, if (merge) SetOptions.Merge else SetOptions.Overwrite)
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFields: String) = setEncoded(encode(data, encodeDefaults)!!, SetOptions.MergeFields(mergeFields.asList()))
suspend inline fun <reified T> set(data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) = setEncoded(encode(data, encodeDefaults)!!, SetOptions.MergeFieldPaths(mergeFieldPaths.asList()))
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, merge: Boolean = false) = setEncoded(
encode(strategy, data, encodeDefaults)!!, if (merge) SetOptions.Merge else SetOptions.Overwrite)
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFields: String) = setEncoded(
encode(strategy, data, encodeDefaults)!!, SetOptions.MergeFields(mergeFields.asList()))
suspend fun <T> set(strategy: SerializationStrategy<T>, data: T, encodeDefaults: Boolean, vararg mergeFieldPaths: FieldPath) = setEncoded(
encode(strategy, data, encodeDefaults)!!, SetOptions.MergeFieldPaths(mergeFieldPaths.asList()))
@PublishedApi
internal abstract suspend fun setEncoded(encodedData: Any, setOptions: SetOptions)
}
expect class DocumentReference internal constructor(nativeValue: NativeDocumentReference) : BaseDocumentReference
// Android
val SetOptions.android: com.google.firebase.firestore.SetOptions? get() = when (this) {
is SetOptions.Merge -> com.google.firebase.firestore.SetOptions.merge()
is SetOptions.Overwrite -> null
is SetOptions.MergeFields -> com.google.firebase.firestore.SetOptions.mergeFields(fields)
is SetOptions.MergeFieldPaths -> com.google.firebase.firestore.SetOptions.mergeFieldPaths(encodedFieldPaths)
}
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) : BaseDocumentReference() {
override suspend fun setEncoded(encodedData: Any, setOptions: SetOptions) {
val task = (setOptions.android?.let {
android.set(encodedData, it)
} ?: android.set(encodedData))
task.await()
}
}
// iOS
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) : BaseDocumentReference() {
override suspend fun setEncoded(encodedData: Any, setOptions: SetOptions) = await {
when (setOptions) {
is SetOptions.Merge -> ios.setData(encodedData as Map<Any?, *>, true, it)
is SetOptions.Overwrite -> ios.setData(encodedData as Map<Any?, *>, false, it)
is SetOptions.MergeFields -> ios.setData(encodedData as Map<Any?, *>, setOptions.fields, it)
is SetOptions.MergeFieldPaths -> ios.setData(encodedData as Map<Any?, *>, setOptions.encodedFieldPaths, it)
}
}
}
// JS
val SetOptions.js: Json get() = when (this) {
is SetOptions.Merge -> json("merge" to true)
is SetOptions.Overwrite -> json("merge" to false)
is SetOptions.MergeFields -> json("mergeFields" to fields.toTypedArray())
is SetOptions.MergeFieldPaths -> json("mergeFields" to encodedFieldPaths.toTypedArray())
}
actual class DocumentReference actual constructor(internal actual val nativeValue: NativeDocumentReference) : BaseDocumentReference() {
override suspend fun setEncoded(encodedData: Any, setOptions: SetOptions) = rethrow {
setDoc(js, encodedData, setOptions.js).await()
}
} Consider how much more maintainable this is. We encode consistently on a single place, and we send to the platform logic in a single place. Before, for these 6 methods, we encoded for each method per platform (so 3 times 6 = 18 times) and for each method we had to write the same coroutine logic with error handling (also 18). With using abstract classes we get 6 encodings and 3 platform methods. You can't do this with extension functions, because that would defeat the purpose where the common step still needs to be implemented on each method per platform. Of course, instead of abstract classes this could be done by interface delegation, but at that point, we will have added extra state to the classes. Similarly im not too fond of using default implementations to the interfaces here, as it would basically constitute an abstract class. |
Are you saying this pattern is not possible? common expect class Something {
expect fun publicApi()
}
internal fun Something.sharedFunction() {} platform
|
No, im saying its not correct to do it like that in my opinion, for maintainability reasons. Because if you use that approach, every time something changes to sharedFunction you have to account for it in 3 times as many places. |
At the very least, we should have
platform
But I dont think that solves the maintainability problem per se. You'll still have to write 3 times as many implementations. |
If you want to account for value classes, I would propose to move to interfaces with default implementations, as that sort of meets both criteria (maintainability and maintaining possibility to inline) |
I'm a little confused here as I don't see how inheritance would make it any better, for reference, here's the inheritance version of my trivial example, as you can see the actual implementation doesn't change but we end up publishing BaseToShareStuffBetweenTheActualImplementations in our public API. common expect class Something : BaseToShareStuffBetweenTheActualImplementations {
expect fun publicApi()
}
abstract class BaseToShareStuffBetweenTheActualImplementations {
internal fun sharedFunction() {}
}
platform
|
Definitely an option but we should hold fire on a refactor like this until the expect/actual language feature is stabilized |
You example is the reverse of what we need though. common
platform
There's a big difference. The abstract class serves only to expose the public API methods and handle default implementation for it. It then splits up the public API method in something that can be done in common and something that must be done on the platform. The public API of |
To compare:
Gives
Whereas
Gives us
While its true this exposes the
in the code for every single platform. That is the benefit your get from this. I've had to make changes to nearly all functions related to encoding for these changes. Thats literally dozens if not hundreds of methods. An abstract class pattern reduces the number of places where you need to apply changes by 66%. |
Ok I understand now! So in that case you could just do: common class Something {
fun publicApi() {
doSomethingCommon()
platformFunction()
}
}
expect internal fun Something.platformFunction()
platform actual fun Something.platformFunction() {
//do other platform specific stuff
} No inheritance needed. But I guess this causes an issue due to not having the native instance property (ios, android, js) in the class |
Which really can only be solved with what you have implemented. One proposal though, since the base classes contain the public API why don't we call those the original names (eg DatabaseReference)and then make the platform specific subclasses internal (eg JsDatabaseReference)? There is still the issue of the publicly accessible android, ios, and js properties but they could be implemented as follows: database.js.kt val DatabaseReference.js get() = (this as JsDatabaseReference).js |
Glad you get my considerations. Im happy to do the renames, but keep in mind that this makes the upgrade less frictionless since people would have to import the |
true, means its a breaking change and we'll need to bump the major version number and change the actual class Database(internal val android: com.firebase.Database)
val Database.android get() = android |
@nbransby finally got around to looking at this. I removed abstract classes in favour of expected wrappers. so instead of abstract class BaseSomething {
fun doSomething() {
doCommon()
doPlatformSpecific()
}
internal abstract fun doPlatformSpecific()
}
expect class Something : BaseSomething {
fun commonPublicAPIButPlatformSpecificImplementation()
} I now have internal expect class NativeSomething {
fun doPlatformSpecific()
fun commonPublicAPIButPlatformSpecificImplementation()
}
class Something internal constructor(internal val native: NativeSomething) {
fun doSomething() {
doCommon()
native.doPlatformSpecific()
}
fun commonPublicAPIButPlatformSpecificImplementation() = native.commonPublicAPIButPlatformSpecificImplementation()
} |
@Daeda88 I believe this might of introduced a bug in the rtdb on js - passing a map to
My guess is that the serialization changes have caused |
The current implementation of serializing classes does not work correctly when dealing with nested classes.
This PR aims to fix this by rewriting the encoding/decoding. This introduces EncodingSettings and DecodingSettings to Database, Firestore and Functions. On Firestore additional async support has been added