/firestore4k

GCP Firestore Client for Kotlin JVM with strict (and relaxed) type-system.

Primary LanguageKotlinApache License 2.0Apache-2.0

Firestore4k

Build Status Build Status Kotlin version badge GitHub license

Firestore Client for Kotlin JVM with strict (and relaxed) type-system.
Inspired by Kotlin Path API, where div / symbol is overloaded to express file path.

See project website at firestore4k.io for detailed documentation & examples.

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.

val firestoreClient = FirestoreClient()

// add (ID auto generated by Firestore)
val userId: String = firestoreClient.add(users, User())
// set
firestoreClient.put(users / UserId("user1"), User())
// get
val user = firestoreClient.get<User>(users / UserId("user1"))
// get all
val messages = firestoreClient.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 Kotlin + 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 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

  • Collections and documents are alternative in hierarchy: <collection>/<document>/<collection>/<document>

  • Top-level is always a collection, not a document.

  • Collection names as plural.

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…​

val firestoreClient = FirestoreClient()

// add (ID auto generated by Firestore)
val userId: String = firestoreClient.add(users, User())
val messageId: String = firestoreClient.add(users / "user1" / messages, Message())

// set
firestoreClient.put(users / "user1", User())
firestoreClient.put(users / "user1" / messages / "message1", Message())

// get
val user: User = firestoreClient.get(users / "user1")
val message: Message = firestoreClient.get(users / "user1" / messages / "message1")
// OR
val user = firestoreClient.get<User>(users / "user1")
val message = firestoreClient.get<Message>(users / "user1" / messages / "message1")

// get all
val users: Collection<User> = firestoreClient.getAll(users)
val messages: Collection<Message> = firestoreClient.getAll(users / "user1" / messages)
// OR
val users = firestoreClient.getAll<User>(users)
val messages = firestoreClient.getAll<Message>(users / "user1" / messages)

// delete
firestoreClient.delete(users / "user1" / messages / "message1")
firestoreClient.deleteAll(users / "user1" / messages)
firestoreClient.delete(users / "user1")
firestoreClient.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.

val firestoreClient = FirestoreClient()

// add (ID auto generated by Firestore)
val userId: String = firestoreClient.add(users, User())
val messageId: String = firestoreClient.add(users / "user1" / messages, Message())

// set
firestoreClient.put(users / UserId("user1"), User())
firestoreClient.put(users / UserId("user1") / messages / MessageId("message1"), Message())

// get
val user = firestoreClient.get(users / UserId("user1"))
val message = firestoreClient.get(users / UserId("user1") / messages / MessageId("message1"))

// get all
val users = firestoreClient.getAll(users)
val messages = firestoreClient.getAll(users / UserId("user1") / messages)

// delete
firestoreClient.deleteDocument(users / UserId("user1") / messages / MessageId("message1"))
firestoreClient.deleteCollection(users / UserId("user1") / messages)
firestoreClient.deleteDocument(users / UserId("user1"))
firestoreClient.deleteCollection(users)

Using annotations + KSP for typed API

⚠️

Experimental

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.
@Serializable
@Collection("users")
data class User(
    val name: String,
)

@IdOf("users")
@JvmInline
value class UserId(private val value: String) {
    override fun toString(): String = value
}

@Serializable
@Collection("messages")
@ChildOf("users")
data class Message(
    val body: String,
)

@IdOf("messages")
@JvmInline
value class MessageId(private val value: String) {
    override fun toString(): String = value
}

Dependencies

Add repository URL https://s01.oss.sonatype.org/content/repositories/snapshots/ for SNAPSHOT versions.

For dynamic API

plugins {
    kotlin("jvm")
    kotlin("plugin.serialization")
}

dependencies {
    implementation("io.firestore4k:dynamic-api:$latestVersion")
}

For typed API

plugins {
    kotlin("jvm")
    kotlin("plugin.serialization")
}

dependencies {
    implementation("io.firestore4k:typed-api:$latestVersion")
}

For typed API with annotations & KSP

⚠️

Experimental

plugins {
    kotlin("jvm")
    kotlin("plugin.serialization")
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("io.firestore4k:typed-api:$latestVersion")
    compileOnly(project("io.firestore4k:annotations:$latestVersion"))
    ksp(project("io.firestore4k:ksp:$latestVersion"))
}

kotlin {
    sourceSets.main {
        kotlin.srcDir("build/generated/ksp/main/kotlin")
    }
    sourceSets.test {
        kotlin.srcDir("build/generated/ksp/test/kotlin")
    }
}