Need support, consulting, or have any other business-related question? Feel free to get in touch.
Like the project, make profit from it, or simply want to thank back? Please consider sponsoring me!
The lightweight, efficient wrapper for Firestore model data, written in Kotlin, with data-binding and Parcelable support.
implementation 'com.otaliastudios:firestore:0.7.0'
kapt 'com.otaliastudios:firestore-compiler:0.7.0'
- Efficient and lightweight
- Compiler to avoid reflection
- Built-in Parcelable implementation
- Built-in Data binding support
- Built-in equals & hashcode
- Written in Kotlin using map / list delegation
- Full replacement for
data class
andParcelize
The key classes here are FirestoreDocument
(for documents), FirestoreMap
and FirestoreList
(for inner maps and lists).
They use Kotlin delegation so you can declare expected fields using the by this
syntax:
@FirestoreClass
class User : FirestoreDocument() {
var type: Int by this
var imageUrl: String? by this
var messages: Messages by this
@FirestoreClass
class Messages : FirestoreList<Message>()
@FirestoreClass
class Message : FirestoreMap<Any?>() {
var from: String by this
var to: String by this
var text: String? by this
}
}
The compiler will inspect your hierarchy and at runtime, it will know how to instantiate fields and inner maps or lists, as long as you provide a no arguments constructor for them. This is valid:
val user = User()
val message = Message()
user.messages.add(message) // We didn't have to instantiate Messages()
Fiels are instantiated automatically and lazily, when requested. The map and list implementations parsed by the compiler are also used when retrieving the document from the network, which makes it much more efficient than using reflection to find setters.
val user: User = documentSnapshot.toFirestoreDocument()
val lastMessage = user.messages.last()
The fields that are marked as not nullable, will be instantiated using their no arguments constructor.
This means that, for example, Int
defaults to 0
. To specify different defaults, simply use an init block:
@FirestoreClass
class User : FirestoreDocument() {
var type: Int by this
init {
type = UserType.ADMIN
}
}
Similar to what Firestore-UI does, we keep a static LruCache
of your documents based on their path.
This means that if you run a query for 50 documents and 20 were already cached, we will reuse their
instance instead of creating new ones.
val user1: User = documentSnapshot.toFirestoreDocument()
val user2: User = documentSnapshot.toFirestoreDocument()
assert(user1 === user2)
Of course, the fields will be updated to reflect the new network data.
Each object will keep (and save to network) some extra fields that are commonly used, plus have useful functions to inspect their state with respect to the database.
val isNew: Boolean = user.isNew() // Whether this object was saved to / comes from network
val createdAt: Timestamp? = user.createdAt // When this object was saved to network for the first time. Null if new
val updatedAt: Timestamp? = user.updatedAt // When this object was saved to network for the last time. Null if new
val reference: DocumentReference = user.getReference() // Throws if new
We also have built-in reliable implementations for equals()
and hashcode()
.
When you update some fields, either some declared field (user.imageUrl = "url"
) or using the backing
map implementation (user["imageUrl"] = "url"
), the document will remember that this specific field was changed
with respect to the original values.
Next call to user.save()
will internally use something like reference.update(mapOf("imageUrl" to "url"))
,
instead of saving the whole object to the database. This is managed automatically and you don't have to worry about it.
This even works with inner fields and maps!
user.family.father.name = "John"
user.save()
// This will not send the whole User, not the whole Family and not even the whole Father.
// It will call reference.update("user.family.father.name", "John") as it should.
The FirestoreDocument
and FirestoreMap
classes extend the BaseObservable
class from the official data binding lib.
Thanks to the compiler, all declared fields will automatically call notifyPropertyChanged()
for you,
which hugely reduce the work needed to implement databinding and two-way databinding.
In fact, all you have to do is add @get:Bindable
to your fields:
@FirestoreClass
class Message : FirestoreDocument() {
@get:Bindable var text: String by this
@get:Bindable var comment: String by this
}
You can now use message.text
and message.comment
in XML layouts and they will be updated
when the data model changes.
Documents, maps and lists implement the Parcelable
interface.
If your object holds metadata that should not by saved to network (for instance, fields that are not marked with by this
),
they can be saved and restored to the Parcel
overriding the class callbacks:
@FirestoreClass
class Message : FirestoreDocument() {
var text: String by this
var comment: String by this
var hasBeenRead = false
override fun onWriteToBundle(bundle: Bundle) {
bundle.putBoolean("hasBeenRead", hasBeenRead)
}
override fun onReadFromBundle(bundle: Bundle) {
hasBeenRead = bundle.getBoolean("hasBeenRead")
}
}
We offer built in parcelers for DocumentReference
, Timestamp
and FieldValue
types.
If your types do not implement parcelable directly, either have them implement it or register
a parceler using FirestoreDocument.registerParceler()
:
class App : Application() {
override fun onCreate() {
registerParceler(GeoPointParceler)
registerParceler(WhateverParceler)
}
object GeoPointParceler : FirestoreDocument.Parceler<GeoPoint>() {
// ...
}
object WhateverParceler : FirestoreDocument.Parceler<Whatever>() {
// ...
}
}
The document class exposes delete()
, save()
and trySave()
methods. They all return a Task<?>
object
from the Google gms library, which you should be used to. This lets you add success and failure callbacks,
as well as chain operations with complex dependencies.
user.delete().addOnSuccessListener {
// User was deleted!
}.addOnFailureListener {
// Something went wrong
}
The delete operation will throw an exception is the object isNew()
.
The save()
method will check if the object is new or comes from server.
- For a new object, internally this will call
reference.set()
to create your object - For a backend object, this will call
reference.update()
. As stated above in the dirty fields chapter, we only update exactly what was changed programmatically.
user.family.father = John()
user.type = User.TYPE_CHILD
user.save().addOnSuccessListener {
// User was saved!
}.addOnFailureListener {
// Something went wrong
}
The trySave()
method follows an opposite procedure.
It will first try to update the fields you specify to network, and if the call succeeds, it will update
the data document too.
user.family.father = null
user.trySave(
"family.father" to John(),
"type" to User.TYPE_CHILD
).addOnSuccessListener {
// User was saved!
assert(user.family.father is John)
}.addOnFailureListener {
// Something went wrong.
assert(user.family.father == null)
}