Getting Started with Firebase Cloud Firestore in iOS Project

This post is brought to you by TestFairy, a mobile testing platform that helps companies streamline their mobile development process and fix bugs faster.
See how TestFairy can help you at testfairy.com

Firebase Cloud Firestore is a new addition to the Firebase product line, it is still in beta, but it’s an upgrade for Firebase Realtime Database. Just like a Firebase Realtime Database, Cloud Firestore aims to help developers (backend and frontend/mobile) by providing a flexible and scalable database to store and sync data. Using Cloud Firestore, it’s easy to get data, listen to real-time changes inside our data, and handle data while users are offline. In this tutorial, we’re going to look at  Cloud Firestore and how to use it inside an iOS project.

Cloud Firestore

Cloud Firestore’s data model is based on NoSQL Document-Oriented Database. The data model may look like a key-value based database in Firebase Realtime Database, but Firestore has more advanced operations, more data types and improved performance. Firestore’s data model looks like this:

It has collections, a collection contains documents, and a document contains data. A document can also have subcollections. This means that the structure of the data is very flexible. The only limitation is that the subcollection can only be nested up to 100 levels deep (which is A LOT).

Setup Firestore inside XCode Project

Make sure you already have a Google account, then log in to https://console.firebase.google.com/. Create a new project and then inside Project settings, click on Add app and select iOS.

Fill out the forms and then you will be asked to download and add GoogleService-Info.plist. If you skip the step to add GoogleService-Info.plist, don’t worry, you can always download it later in app list.

Run pod install and wait for it to finish as there are many dependencies need to be installed. Then build your XCode project.

Inside your didFinishLaunchingWithOptions add Firebase initialization:

FirebaseApp.configure()

Run your project, and if the console shows no error, then the setup is a success. To get the default instance of a Firestore database, declare the variable with type Firestore and call the firestore method.

var db: Firestore!

func viewDidLoad() {
    super.viewDidLoad()

    db = Firestore.firestore()
}

firestore will return a default (singleton) Firestore database instance, and it will return the same instance wherever you call it.

Add Data

To add new data, first, we need to have a collection and a document inside that collection. There are 2 ways to add a document:

  • Named document -use this one if you already have a meaningful name for your data collection or if you want to manually handle the naming of your data.
db.collection("animal").document("bird").setData([
    "name": "Peacock",
    "type": "Herbivore",
    "colors": ["Green", "White", "Blue", "Black"]
])

db.collection("animal").document("mammal").setData([
    "name": "Bat",
    "type": "Herbivore",
    "norturnal": true,
    "colors": ["Brown", "Black"]
], merge: true)

For the second code above, if you add the merge: true parameter, the data parameter will update the “mammal” document if it can find it. If the “mammal” document isn’t present, the code will add a new “mammal” document and set its value with the data parameter.

  • Generated document name – use this one if a  specific document name is not essential and you want Firestore to handle it for you. It will generate a hash string for the document name.
let ref = db.collection("class").addDocument(data: [
    "name": "Steve Gates",
    "gender": "Male"
]) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ref: \(ref)")
    }
}

If you choose to let Firestore handle the document name generation, whenever a document is added it will return a reference to the newly added document. You can use this reference for further data manipulation.

Update Data

If you have a named document, you can update your data by calling setData and set merge parameter to true.

db.collection("animal").document("bird").setData(["type": "Carnivore"], merge: true)

However, a standard approach to update the document data is through a reference because by using a reference, we can know whether or not the update was a success. To update “bird” document above, we can use this code:

let ref = db.collection("animal").document("bird")
ref.updateData([
    "type": "Omnivore"
]) { err in
    if let err = err {
        print("Error updating document, reason: \(err)")
    } else {
        print("Document successfully updated")
    }
}

To update a document with a hash value, the above code is still valid. Usually, this document’s data is shown inside a list of UITableView/UICollectionView. When a user selects an item, we can retrieve this document ID.

db.collection("class").getDocuments { [weak self] (snapshot, error) in
    ...
    self?.documents = snapshot.documents
    self?.tableView.reloadData
}

/// implementation of a tableview delegate

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let docId = documents[indexPath.row].documentID

    let ref = db.collection("class").document(docId)
    ref.updateData([
        "name": "Bill Jobs"
    ]) { err in
        if let err = err {
            print("Unable to update data, reason: \(err)")
        } else {
            print("Data updated successfully")
        }
    }
}

Delete Data

To delete a document, get the reference to the document and call delete method.

db.collections("animal").document("bird").delete() { err in
    if let err = err {
        print("Unable to delete document, reason: \(err)")
    } else {
        print("Data deleted successfully")
    }
}

To delete a field in the document data, update the field with FieldValue.delete() as its value. Maybe you wonder why we don’t just set the data to null? We can’t do that because null is a valid value and a valid data type.

db.collections("animal").document("mammal").updateData(["norturnal": FieldValue.delete()]) { err in
    if let err = err {
        print("Unable to delete document, reason: \(err)")
    } else {
        print("Data deleted successfully")
    }
}

Transaction

Add and update data are not atomic, this means that if a concurrent update to a document happens simultaneously , the operations may overlap. For example, if you have a counter app that increments a counter every time a user presses a button, when 3 users press a button at the same time, if the counter value is 0 at the beginning, the final value should be 3. However, a non-atomic operation produces 1 as the final value. An atomic operation guarantees that the final value is 3. Cloud Firestore supports atomic operation via Transaction. To update a counter like the example  above via Transaction:

let ref = db.collection("statistic").document("visitor")
db.runTransaction({ (transaction, errPointer) -> Any? in
    let dosSnapshot: DocumentSnapshot

    // get the document via transaction
    do {
        try docSnapshot = transaction.getDocument(ref)
    } catch let fetchError as NSError {
        errPointer?.pointee = fetchError
        return nil
    }

    // get the counter value
    guard let counter = docSnapshot.data()?["counter"] as? Int else {
        let err = NSError(domain: "AppErrorDomain", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unable to retrieve counter"])
        errPointer?.pointee = err
        return nil
    }

    // update counter value via transaction
    transaction.updateData(["counter": counter + 1], forDocument: self.ref)
            return nil
        }) { (object, err) in
            if let err = err {
                print("Transaction failed, reason: \(err)")
            } else {
                print("Transaction success"g)
            }
        }

Transaction allows Read and Write operation. However, Read operations must come before Write operations. If you only want Write operations, you can use Batched Writes instead.

The limitations of Transaction are:

  1. A transaction always fails when the device is offline.
  2. Read operations must come before Write operations.
  3. A transaction always completely succeeds or fails all operations. If something happens when operations are still running, Cloud Firestore automatically does the rollback operation.
  4. The transaction function might run more than once when a concurrent edit affects a document that the transaction has read.
  5. Do not modify application state inside transaction function, because this function is not guaranteed to run on the UI thread. If you want to get any information from a transaction, return the value instead of nil. In the above code, modify the codes like this:
// update counter value via transaction
let newCounter = counter + 1
transaction.updateData(["counter": newCounter], forDocument: self.ref)
return newCounter

Batched Writes

Batched Writes offers a simpler code for writing data compared to Transaction. It also has fewer failure cases because no read operations are allowed. To use Batched Writes, first, we need to initialize a new write batch, then we add however many  write operations we need inside the batch (up to 500 operations) and finally we commit the batch.

let batch = db.batch()

let mammalRef = db.collection("animal").document("mammal")
batch.updateData(["name": "Tarsius"], forDocument: mammalRef)

let birdRef = db.collection("animal").document("bird")
batch.deleteDocument(birdRef)

batch.commit() { err in
    if let err = err {
        print("Batch write failed, reason: \(err)")
    } else {
        print("Batch write succeeded.")
    }
}

Get Data (Once)

There are two ways to query data in Firestore, get data (once) and get realtime data. The difference is that in  realtime data we use a listener that gets notified whenever there is a data update. This update also includes any updates that happen in the children.

To get a document data, use getDocument:

let ref = db.collection("animal").document("mammal")
ref.getDocument { (snapshot, err) in
    if let data = snapshot?.data() {
        print(data["name"])
    } else {
        print("Couldn't find the document")
    }
}

To get a list of documents inside a collection, use getDocuments:

let collectionRef = db.collection("animal")
collectionRef.getDocuments { (querySnapshot, err) in
    if let docs = querySnapshot?.documents {
        for docSnapshot in docs {
            print(docSnapshot.data())
        }
    }
}

To filter a list of documents inside a collection, instead of filtering the data after the data is returned, we can use Cloud Firestore queries to do the filtering before the data is returned. Doing it this way reduces the data size and reduces the time and power needed to filter it inside the device.

let query = db.collection("animal").whereField("name", isEqualTo: "Peacock")
// or
// query = db.collection("animal").whereField("colors", arrayContains: "Black")
// to filter data by an array value

query.getDocuments { (querySnapshot, err) in
    if let docs = querySnapshot?.documents {
        for docSnapshot in docs {
            print(docSnapshot.data())
        }
    }
}

Get Realtime Data

A case where realtime data can be very useful is a chat app. In a chat app, it’s best to show a friend’s message as soon as the message is  received . Cloud Firestore provides addSnapshotListener to listen for any changes happening in documents or collections. The changes it will listen for include new data added, data updated, or data deleted.

db.collection("animal").document("mammal").addSnapshotListener { (snapshot, error) in
    if let err = error {
        print(err.localizedDescription)
        return
    }
    
    if let data = snapshot?.data() {
        print(data)
    }
}

Just like in the Get Data (Once) section, you can also listen to multiple document changes or filter the data and listen to the filtered data changes.

// Listen to multiple documents changes
let collectionRef = db.collection("animal")
collectionRef.addSnapshotListener { (querySnapshot, err) in
    ...
}

// Listen fo filtered documents changes
let query = db.collection("animal").whereField("name", isEqualTo: "Peacock")
query.getDocuments { (querySnapshot, err) in
    ...
}

Reference

Documentation: https://firebase.google.com/docs/firestore/