arrow left
Back to Developer Education

    Firestore Data Modeling and Query Customization in Android

    Firestore Data Modeling and Query Customization in Android

    Firestore database is a modern, object-based database that stores data in JSON format. In this tutorial, we will learn how to prepare data, design the database, and perform simple and complex queries using the Android platform. <!--more-->

    Prerequisites

    To follow along with this tutorial, you will need the following:

    Creating data models

    A data model describes the data that is stored in a database. A good model allows you to structure the data in a way that is easy to use and understand. This makes the database more maintainable.

    Firestore data model

    In Firestore, data is stored in collections identified by a unique path. Each collection contains documents, which are key-value pairs.

    A document can contain any number of fields, including sub-documents and sub-collections.

    Implementing Firestore in Android

    First, create a new project in Android Studio.

    Then head to the Firebase console and create a new firebase project.

    This will require you to have a Google account. Note that you must use the same account to sign into Android Studio and the Firebase console.

    If you are not familiar with creating firebase projects, you can read more about it here.

    Next open the Firestore tab and click on the create database button. This will create a new Firestore database.

    It's important to use the test rules to ensure that the database is working correctly before you deploy the project. This is done by clicking on the test rules button.

    Warning: Test rules allow anyone to access the database. This is, therefore, not recommended for production use. Instead, you should use secure rules and authentication to restrict access to the database.

    Finally in this step, we need to connect our app to Firebase. Click on the Tools tab in Android Studio, select Firebase then connect the projects by clicking the Connect to Firebase button.

    This will take you to the Firebase console where you need to select the project we just created.

    After doing so, navigate back to Android Studio, add Firestore SDK by clicking the add cloud Firestore button in the Firebase assistant.

    Creating a database instance

    In your activity file, add a FirebaseFirestore instance, as shown below:

    lateinit var database: FirebaseFirestore
    
    override fun onCreate(savedInstanceState: Bundle?) {
        database = FirebaseFirestore.getInstance()
    }
    

    Designing the database

    Moving on, we need to populate our database with data that we will execute queries against.

    Example use case

    Let's take an example of an agricultural farm in which we have a collection of crops that can be categorized into fruits, vegetables, and trees.

    To design a Firestore database for this, we need to create a collection named farm that holds a document named crops. This document will hold sub-collections named fruits, vegetables, and trees.

    It should look as shown below:

    Database design

    You can design this using the console or using the code which will be discussed in this tutorial.

    Designing the app UI

    Add the following code to your layout file:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:padding="8dp"
        android:layout_height="match_parent" >
        <!--let's keep things simple 😎-->
        <Button
            android:id="@+id/btnSaveData"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Save data"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="1.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.85" />
    
        <Button
            android:id="@+id/btnReadData"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Read data"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="1.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/btnSaveData"
            app:layout_constraintVertical_bias="0.5" />
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    Designing the app data model

    Based on the use case described above, we need to create an enum class for the crop category and a data class to define the structure of the crops object/document.

    Enum class

    enum class CropCategory {
        FRUIT, VEGETABLE, TREE
    }
    

    Data class

    data class Crop(
        val name: String,
        var count: Int,
        val category: CropCategory
    )
    

    Generating dummy data

    Here, we will create random data for our database. In a production project, you would probably get inputs from the user.

    In your activity file, create a function that will be triggered when the Save data button is clicked.

    private fun handleClicks() {
        binding.btnSaveData.setOnClickListener {
            // Create Sample data for demonstration purposes
    
            val crops = arrayListOf<Crop>(
                Crop("Apple", 10, CropCategory.FRUIT),
                Crop("Apricots", 5, CropCategory.FRUIT),
                Crop("Avocado", 8, CropCategory.FRUIT),
                Crop("Banana", 24, CropCategory.FRUIT),
                Crop("Blackberries", 16, CropCategory.FRUIT),
    
                Crop("Broccoli", 3, CropCategory.VEGETABLE),
                Crop("Brooklime", 20, CropCategory.VEGETABLE),
                Crop("Brussels sprouts", 10, CropCategory.VEGETABLE),
                Crop("Cabbage   Brassica", 15, CropCategory.VEGETABLE),
    
                Crop("White Ash", 5, CropCategory.TREE),
                Crop("Bigot Aspen", 1, CropCategory.TREE),
                Crop("Quaking Aspen", 3, CropCategory.TREE),
                Crop("Basswood", 1, CropCategory.TREE),
                Crop("American Beech", 3, CropCategory.TREE)
            )
        }
    }
    

    Remember to call this function in the onCreate method of your MainActivity.

    Saving data to Firestore

    Create a function named saveData and add the following code:

    private fun saveData(crops: ArrayList<Crop>) {
        // NOTE: This should be done in a ViewModel
    
        val cropsDoc: DocumentReference = database.collection("farm")
            .document("crops")
    
        crops.forEach { crop ->
            when (crop.category) {
                CropCategory.FRUIT -> {
                    cropsDoc.collection("fruits").add(crop)
                }
                CropCategory.VEGETABLE -> {
                    cropsDoc.collection("vegetables").add(crop)
                }
                // this is a Tree
                else -> {
                    cropsDoc.collection("trees").add(crop)
                }
            }
        }
    }
    

    Here, we have used the crops document reference (cropsDoc) to save every crop in the respective sub-collection.

    We will call the saveData function and pass the crops array to a coroutine when the btnSaveData button is clicked, as demonstrated below:

    // Launch a Coroutine to run the save-data Job
    
    CoroutineScope(Dispatchers.IO).launch {
        saveData(crops)
    }
    

    Testing the application

    After opening the app on your device, click btnSaveData button and then navigate to the Firebase console to confirm whether the upload is working as expected.

    Note: Multiple clicks will produce duplicate data although each document has a unique id. This is good for testing purposes but not for production.

    You should expect to see something similar to this:

    Saved data

    Performing Queries

    To query the data, we will use the get() method of the DocumentReference class. This method returns a DocumentSnapshot object.

    Firestore query operators

    The following operators are available for data querying:

    • (<) Less than
    • (>=) Greater than or equal to
    • (==) Equal to
    • (>) Greater than
    • (<=) Less than or equal to
    • (array-contains) Checks if the value is present in the array.

    The comparison operator is explicitly named in the method for Android-based database queries.

    These operators are used in the where() method as one of the parameters together with the field to filter on and the value to compare against.

    You might have noticed a warning in the firebase console indicating that the crops document will not appear in queries since it doesn't exist. This because it only consist of collections. To fix this, add at least one document to it.

    Simple queries

    These queries involve a single action and condition. For example, to query for documents in the fruits collection, we can use the following code:

    val docReference = database.collection("farm/crops/fruits")
    docReference.get()
    

    If you had already queried for the same data and cached the result, you can switch the data source as shown below:

    val docReference = database.collection("farm/crops/fruits")
    docReference.get(Source.CACHE)
    

    To track a query's state or progress, we can use the addOnCompleteListener lambda function that allows you to handle events such as the success or failure of the query.

    val docReference = database.collection("farm/crops/fruits")
    docReference.get().addOnCompleteListener { queryTask ->
        when {
            queryTask.isSuccessful -> {
                // handle success event
            }
            queryTask.isCanceled -> {
                // handle cancellation event
            }
            else -> {
                // handle any other event
            }
        }
    }
    

    To get a specific document, we can use the get() method and pass the document id as a parameter.

    This is very useful when deleting or updating the fields but you must provide an accurate id.

    val documentId = "THEDOCID" // This can be gotten from a previous query
    val doc = database.collection("farm/crop/fruits").document().get(documentId)
    

    Compound queries

    In compound queries, we can combine multiple conditions to form a single query. This is useful when we want to perform a query that returns specific documents.

    We can also create objects for performing complex queries.

    For example, to query for all documents in the fruits collection that have a count greater than 15, we can use the following code:

    val TAG = "MainActivity"
    val docReference = database.collection("farm/crops/fruits")
    
    docReference.whereGreaterThan("count", 15).get().addOnSuccessListener {querySnapshot ->
        for (doc in querySnapshot) {
            Log.d(TAG, "id: ${doc.id} name: ${doc.data.getValue("name")}")
        }
    }
    

    The code above returns two fruits, Blackberries and Banana because their count satisfies the condition. Refer to the dummy data to confirm.

    Query -Greater than

    Multiple operators can be chained to form a more specific query. For example, we can get all documents in the fruits collection that have a count greater than 5 but less than 20.

    val TAG = "MainActivity"
    val docReference = database.collection("farm/crops/fruits")
    // this serves as a range operator
    docReference.whereGreaterThan("count", 5).whereLessThan("count", 20).get().addOnSuccessListener {querySnapshot ->
        for (doc in querySnapshot) {
            Log.d(TAG, "id: ${doc.id} name: ${doc.data.getValue("name")}")
        }
    }
    

    Note that you can not filter fields that do not belong to the same document.

    Indexing custom operators

    Indexes are used to make queries more efficient. Firestore automatically creates indexes as data is saved. However, for custom queries, we need to create indexes for the fields involved.

    This is done by navigating to the indexes tab in the firebase console and clicking on the Add index button. At least two fields must be selected.

    Another way to create indexes is to run the app and you'll get a link in the LogCat's debug section that directs you to the console and fills the fields for you. This is highly recommended for accuracy and simplicity.

    The order of the conditions/filter operations is important since the result of the first one is passed to the next one in the chain.

    Therefore, you should always start with less specific or the most relevant conditions.

    Conclusion

    In this tutorial, we have covered the fundamental concepts of modeling data in Firestore and how to query it.

    The knowledge gained in this tutorial can be used to build a robust and scalable application. Keep practicing to gain a better understanding of custom queries and indexing.

    Happy Coding!


    Peer Review Contributions by: Eric Gacoki

    Published on: Sep 28, 2021
    Updated on: Jul 15, 2024
    CTA

    Start your journey with Cloudzilla

    With Cloudzilla, apps freely roam across a global cloud with unbeatable simplicity and cost efficiency