Firestore4k
|
Experimental |
Firestore Kotlin Client with strict (and relaxed) type-system.
Inspired by Kotlin Path API, where div /
symbol is overloaded to express file path.
Code preview
DSL to express Firestore collection & document path
val users = rootCollection<User, UserId>("users")
val messages = users.subCollection<Message, MessageId>("messages")
// /users
users
// /users/user1
users / UserId("user1")
// /users/user1/message
users / UserId("user1") / messages
// /users/user1/message/message1
users / UserId("user1") / messages / MessageId("message1")
Use collection & document path for operations.
// add (ID auto generated by Firestore) val userId: String = add(users, User()) // set put(users / UserId("user1"), User()) // get val user = get<User>(users / UserId("user1")) // get all val messages = getAll<Message>(users / UserId("user1") / messages)
Define flexible
dynamic or strict + type-inference
typed collection hierarchy.
// Using `dynamic` API val users = collection("users") val messages = collection("messages") // OR // Using `typed` API val users = rootCollection<User, UserId>("users") val messages = users.subCollection<Message, MessageId>("messages")
Preface
GCP Firestore client for Koltin + Gradle project.
Firestore is a NoSQL document-store (tree based) database-as-a-service from Google Cloud Platform.
API in two flavors:
-
Dynamic → Flexible dynamic API with relaxed type checks for DB schema.
-
Typed → Typed-API with type safety for DB schema.
For Typed API, you can optionally use annotations along with KSP (Kotlin Symbol Processing) to autogenerate some of the boilerplate code.
Sample code
Firestore stores the DB in alternate hierarchy of collections and documents.
Ref: https://firebase.google.com/docs/firestore/manage-data/structure-data
This structure is a mirror of the Resource Oriented Design of REST API Design guidelines recommended by Google.
Ref: https://cloud.google.com/apis/design/resources
-
Collection names as plural.
-
Collections and documents are alternative in hierarchy:
/ / / -
Top-level is always a collection, not a document.
For the sample code, I will use a root (top-level) collection: users and its sub (child) collection: messages.
Path | Description |
---|---|
users |
users as root collection |
users/user1 |
user1 document under users root collection |
users/user1/messages |
messages sub-collection under user1 document |
users/user1/messages/message1 |
message1 document under messages sub-collection under user1 document |
For dynamic API
Define collections
val users = collection("users")
val messages = collection("messages")
And then use them in PATHs of CRUD operations such as…
// add (ID auto generated by Firestore) val userId: String = add(users, User()) val messageId: String = add(users / "user1" / messages, Message()) // set put(users / "user1", User()) put(users / "user1" / messages / "message1", Message()) // get val user: User = get(users / "user1") val message: Message = get(users / "user1" / messages / "message1") // OR val user = get<User>(users / "user1") val message = get<Message>(users / "user1" / messages / "message1") // get all val users: Collection<User> = getAll(users) val messages: Collection<Message> = getAll(users / "user1" / messages) // OR val users = getAll<User>(users) val messages = getAll<Message>(users / "user1" / messages) // delete delete(users / "user1" / messages / "message1") deleteAll(users / "user1" / messages) delete(users / "user1") deleteAll(users)
For typed API
Define collection hierarchy and type bindings
val users = rootCollection<User>("users")
val messages = users.subCollection<User, Message>("messages")
CRUD operations for typed are similar to dynamic, but with type safety & inference.
-
So, users have to be root collection and messages under it.
-
Code accepts User / Message objects only in their respective add and put functions.
-
Type inference for return value of object & collection in get and getAll functions respectively.
// add (ID auto generated by Firestore)
val userId: String = add(users, User())
val messageId: String = add(users / "user1" / messages, Message())
// set
put(users / UserId("user1"), User())
put(users / UserId("user1") / messages / MessageId("message1"), Message())
// get
val user = get(users / UserId("user1"))
val message = get(users / UserId("user1") / messages / MessageId("message1"))
// get all
val users = getAll(users)
val messages = getAll(users / UserId("user1") / messages)
// delete
delete(users / UserId("user1") / messages / MessageId("message1"))
deleteAll(users / UserId("user1") / messages)
delete(users / UserId("user1"))
deleteAll(users)
Using annotations + KSP for typed API
Collection hierarchy and type bindings are autogenerated using annotations.
But for simple cases, it is not worth the complexity since it is more verbose.
// root collection will not have @[ChildOf] annotation.
@Collection("users")
data class User(
val name: String,
) {
// Needed for DSL
companion object
}
@IdOf("users")
@JvmInline
value class UserId(private val value: String) {
override fun toString(): String = value
}
@Collection("messages")
@ChildOf("users")
data class Message(
val body: String,
) {
// Needed for DSL
companion object
}
@IdOf("messages")
@JvmInline
value class MessageId(private val value: String) {
override fun toString(): String = value
}
Dependencies
For dynamic API
dependencies {
implementation("dev.vihang.firestore4k:dynamic:$latestVersion")
}
For typed API
dependencies {
implementation("dev.vihang.firestore4k:typed:$latestVersion")
}
For typed API with annotations & KSP
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("dev.vihang.firestore4k:typed:$latestVersion")
compileOnly(project("dev.vihang.firestore4k:annotations:$latestVersion"))
ksp(project("dev.vihang.firestore4k:ksp:$latestVersion"))
}