Deserializing nested firebase firestore classes on Android

Viewed 358

Issue Description

I'm trying to deserialize a simple firebase firestore document for an android app.
The issue is with kotlin nested classes deserialization. And it should be a pretty simple one but it somehow just does not seem to work.

I have a class which would like to store as a kotlin data class the contents of a map on the remote document.
Even though the @PropertyName-supplied field names match with the associated class property names, and the non-nested class (called Bucket) receives all of the "primitive" data correctly, its nested classes just seem not to be filled in with the remote data, falling back to their empty-constructor values you must provide, thus creating a garbage object.

Implementation Details

The Firebase Bucket document data structure is as follows:

firebase data structure

I created the following class to represent the whole document:

@Parcelize
data class Bucket(
    @PropertyName("id")
    val id: String,

    @PropertyName("name")
    val name: String,
    @PropertyName("description")
    val description: String,

    @PropertyName("bucket_style")
    val style: BucketStyle,

    @PropertyName("bucket_dates")
    val dates: BucketDates
) : Parcelable {
    constructor() : this("mock.id", "mock.name", "mock.desc", BucketStyle(), BucketDates(), listOf())
}

As an example, with the BucketStyle class:

@Parcelize
data class BucketStyle(
    @PropertyName("icon_family")
    val iconFamily: Int,

    @PropertyName("icon_id")
    val iconId: Int,

    @PropertyName("color_id")
    val colorId: Int
) : Parcelable {
    constructor() : this(0, 0, 1)
}

And symmetrically:

@Parcelize
data class BucketDates(
    @PropertyName("creation_date_timestamp")
    val creationDate: Timestamp,

    @PropertyName("last_edit_timestamp")
    val lastEdit: Timestamp
) : Parcelable {
    constructor() : this(Timestamp.now(), Timestamp.now())
}

And I deserialize it (in reality all the documents in a collection using a real-time listener) using the default firebase-provided method (28.4.2 version of firebase-bom)

buckets.addSnapshotListener { data, error -> 
    val update: List<Bucket> = data.toObjects(Bucket::class.java)
}

And the inner classes are not populated, as shown in this debugger, as the server value differs, and they use their default one, while IDs, names and everything else "primitive" works correctly.
enter image description here

I do not get errors, just a logcat warning while executing:

W/Firestore: (23.0.4) [CustomClassMapper]: No setter/field for bucket_style found on class com.[redacted].model.bucket.Bucket
W/Firestore: (23.0.4) [CustomClassMapper]: No setter/field for bucket_dates found on class com.[redacted].model.bucket.Bucket

Additional Analysis

Those logs perplexed me, because I really have created getters and setters since Bucket is a data class. Or have I?

I decompiled the kotlin file to java and I realized that there is no getter named getbucket_style() for our object:

@PropertyName("bucket_style")
@NotNull
private final BucketStyle style;

@NotNull
public final BucketStyle getStyle() {
    return this.style;
}

We only have getStyle().

We can try to do a trick by renaming the kotlin style property to bucket_style (To match firestore document field name) this way in Bucket:

@PropertyName("bucket_style")
val bucket_style: BucketStyle,

The java decompilation becomes:

@PropertyName("bucket_style")
@NotNull
private final BucketStyle bucket_style;

@NotNull
public final BucketStyle getBucket_style() {
    return this.bucket_style;
}

But the warning stays constant. The generated getter has an uppercase "B" (that's the syntax convention for generating getters in kotlin data classes).
However, if I create a custom getter named getbucket_style() in the original Bucket, as in:

@Parcelize
data class Bucket(
    @PropertyName("id")
    val id: String,

    @PropertyName("name")
    val name: String,
    @PropertyName("description")
    val description: String,

    @PropertyName("bucket_style")
    val style: BucketStyle,

    @PropertyName("bucket_dates")
    val dates: BucketDates
) : Parcelable {

    fun getbucket_style() = style

    constructor() : this("mocker.id", "mocker.name", "mocker.desc", BucketStyle(), BucketDates(), listOf())
}

Whose java decompilation looks like:

@PropertyName("bucket_style")
@NotNull
private final BucketStyle style;


@NotNull
public final BucketStyle getbucket_style() {
    return this.style;
}

@NotNull
public final BucketStyle getStyle() {
    return this.style;
}

Then the warning changes!

W/Firestore: (23.0.4) [CustomClassMapper]: No setter/field for style found on class com.[redacted].model.bucket.Bucket (fields/setters are case sensitive!)

I have no idea why. Also note that on the first warning, the property is referred to as bucket_style, and here as style.

If I do the same trick as before, renaming style to bucket_style, we get this:

@Parcelize
data class Bucket(
    @PropertyName("id")
    val id: String,

    @PropertyName("name")
    val name: String,
    @PropertyName("description")
    val description: String,

    @PropertyName("bucket_style")
    val bucket_style: BucketStyle,

    @PropertyName("bucket_dates")
    val dates: BucketDates
) : Parcelable {

    fun getbucket_style() = bucket_style

    constructor() : this("mock.id", "mock.name", "mock.desc", BucketStyle(), BucketDates(), listOf())
}

Whose java decompilation looks like

@NotNull
private final String description;

@PropertyName("bucket_style")
@NotNull
public final BucketStyle getbucket_style() {
    return this.bucket_style;
}

@NotNull
public final BucketStyle getBucket_style() {
    return this.bucket_style;
}

This crashes without warnings, because (I believe) there are two functions with the same, case-insensitive name:

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.[redacted], PID: 18996
    java.lang.RuntimeException: Found conflicting getters for name getbucket_style on class com.[redacted].model.bucket.Bucket

Concolusions

The alternative would be to use kotlin Maps to model the nested firestore maps, but classes are much nicer to work with.
I also tried to un-parcele all the classes but the problem persists.
Is there a clean solution / something wrong with the implementation?

EDIT

The workaround is changing the class properties names to match the firestore document field names.

My issue is a special case of a more general bug. It isn't true that deserialization doesn't work for subclasses. It does't work for any class field whose name does not match firebases (in my case).

Indeed, all I had to do was to change the map name from "bucket_style" to "style", and the inner fields (in the now "style" map) to use camel casing (to match my app declarations) and voila, data is now going where it's supposed to. Now I'm using the same names on the backend and on my model, so that the deserializer doesn't get confused.

Still, I don't know how this doesn't work. I believe it is something I am triggering (I refuse to believe that up until now nobody had this issue and reported it), even though my app is still small, follow de facto guidelines and uses firebase exclusively for firestore.

1 Answers

I suspect this is a bug with Firestore's @PropertyName annotation and Kotlin's data classes and you'll have to find a workaround.

The first thing to note is that Kotlin data classes do not have setters. The value with which a property is initialized in its constructor cannot be changed. Many JSON deserializers are used to the "Java way" of doing things: look for a no-argument constructor, invoke it, and then look for setters to set each field found in the JSON document. The error No setter/field for bucket_style found on class com.[redacted].model.bucket.Bucket tells us that Firestore is invoking the no-arg constructor (with your defaults) and then looking for setters.

What's curious is that Firestore is correctly setting fields on the parent object, Bucket, which also has a no-arg constructor.

A few things worth investigating:

  • Does Firestore correctly deserialize a BucketStyle object if it is directly stored with Firebase (e.g. if you add an id and store it un-nested in Firestore)?

  • Does the problem resolve if you delete your no-arg constructor (with your defaults) on BucketStyle? This may force the Firestore deserializer to use your constructor with named arguments.

  • Does the problem resolve if you remove data from your class declaration and change the val fields to var fields? Under the hood, this should create setters for each field.

Related