Mobile coders have been taking advantage of Google’s Mobile Backend as a Service (MBaaS) platform Firebase Realtime Database for many years, helping them focus on building features for their apps without having to worry about the back-end infrastructure and database. By making it easy to store and persist data in the cloud and take care of authentication and security, Firebase allows coders to focus on the client side.
Last year, Google announced yet another back-end database solution, Cloud Firestore, built from the ground up with the promise of greater scalability and intuitiveness. However, this introduced some confusion as to its place in relation to Google’s already existing flagship product, Firebase Realtime Database. This tutorial will outline the differences between the two platforms and the distinct advantages of each. You will learn how to work with Firestore document references, as well as reading, writing, updating and deleting data in real time, by building a simple reminders app.
Objectives of This Tutorial
This tutorial will expose you to Cloud Firestore. You'll learn how to leverage the platform for real-time database persistence and synchronization. We'll cover the following topics:
- what Cloud Firestore is
- the Firestore data model
- setting up Cloud Firestore
- creating and working with Cloud Firestore references
- reading data in real time from Cloud Firestore
- creating, updating and deleting data
- filtering and compound queries
Assumed Knowledge
This tutorial assumes you have had some exposure to Firebase and a background developing with Swift and Xcode.
What Is Cloud Firestore?
Like Firebase Realtime Database, Firestore provides mobile and web developers with a cross-platform cloud solution to persist data in real time, regardless of network latency or internet connectivity, as well as seamless integration with the Google Cloud Platform suite of products. Along with these similarities, there are distinct advantages and disadvantages that differentiate one from the other.
Data Model
On a fundamental level, Realtime Database stores data as one large, monolithic, hierarchical JSON tree, whereas Firestore organizes data in documents and collections, as well as sub-collections. This requires less denormalization. Storing data in one JSON tree has the benefits of simplicity when it comes to working with simple data requirements; however, it becomes more cumbersome at scale when working with more complex hierarchical data.
Offline Support
Both products offer offline support, actively caching data in queues when there is latent or no network connectivity—synchronising local changes back to the back end when possible. Firestore supports offline synchronisation for web apps in addition to mobile apps, whereas the Realtime Database only enables mobile synchronization.
Queries and Transactions
Realtime Database only supports limited sorting and filtering capabilities—you can only sort or filter on a property level, but not both, in a single query. Queries are also deep, meaning they return a large sub-tree of results back. The product only supports simple write and transaction operations which require a completion callback.
Firestore, on the other hand, introduces index queries with compound sorting and filtering, allowing you to combine actions to create chain filters and sorting. You can also execute shallow queries returning sub-collections in lieu of the entire collection you would get with Realtime Database. Transactions are atomic in nature, whether you send a batch operation or single, with transactions repeating automatically until concluded. Additionally, Realtime Database only supports individual write transactions, whereas Firestore affords batch operations atomically.
Performance and Scalability
The Realtime Database, as you would expect, is quite robust and has low latency. However, databases are restricted to single regions, subject to zonal availability. Firestore, on the other hand, houses data horizontally across multiple zones and regions to ensure true global availability, scalability, and reliability. In fact, Google has promised that Firestore will be more reliable than Realtime Database.
Another shortcoming of the Realtime Database is the limitation to 100,000 concurrent users (100,000 concurrent connections and 1,000 writes/second in a single database) after which you would have to shard your database (split your database into multiple databases) in order to support more users. Firestore automatically scales across multiple instances without you having to intervene.
Designed from the ground up with scalability in mind, Firestore has a new schematic architecture that replicates data across multiple regions, takes care of authentication, and handles other security-related matters all within its client-side SDK. Its new data model is more intuitive than Firebase's, more closely resembling other comparable NoSQL database solutions like MongoDB, while providing a more robust querying engine.
Security
Finally, Realtime Database, as you know from our previous tutorials, manages security through cascading rules with separate validation triggers. This works with Firebase Database Rules, validating your data separately. Firestore, on the other hand, provides a simpler yet more powerful security model taking advantage of Cloud Firestore Security Rules and Identity and Access Management (IAM), with data validation excepted automatically.
The Firestore Data Model
Firestore is a NoSQL document-based database, consisting of collections of documents, each of which contains data. As it's a NoSQL database, you won’t get tables, rows, and other elements you would find in a relational database, but instead sets of key/value pairs that you would find within documents.
You create documents and collections implicitly by assigning data to a document, and if the document or collection doesn’t exist, it will automatically be created for you, as the collection always has to be the root (first) node. Here is a simple Tasks example schema of the project you will be working on shortly, consisting of the Tasks collection, as well as numerous documents containing two fields, the name (string), and a flag for whether the task is done (boolean).
Let’s decompose each of the elements so you can understand them better.
Collections
Synonymous with database tables in the SQL world, collections contain one or more documents. Collections have to be the root elements in your schema and can only contain documents, not other collections. However, you can refer to a document which in turn refers to collections (sub-collections).
In the diagram above, a task consists of two primitive fields (name and done) as well as a sub-collection (sub-task) which consists of two primitive fields of its own.
Documents
Documents consist of key/value pairs, with the values having one of the following types:
- primitive fields (such as strings, numbers, boolean)
- complex nested objects (lists or arrays of primitives)
- sub-collections
Nested objects are also called maps and can be represented as follows, within the document. The following is an example of a nested object and array, respectively:
ID: 2422892 //primitive name: “Remember to buy milk” detail: //nested object notes: "This is a task to buy milk from the store" created: 2017-04-09 due: 2017-04-10 done: false notify: ["2F22-89R2", "L092-G623", "H00V-T4S1"] ...
For more information on the supported data types, refer to Google’s Data Types documentation. Next, you will set up a project to work with Cloud Firestore.
Setting Up the Project
If you have worked with Firebase before, a lot of this should be familiar to you. Otherwise, you will need to create an account in Firebase and follow the instructions in the ‘Set Up the Project’ section of our previous tutorial, Get Started With Firebase Authentication for iOS .
To follow along with this tutorial, clone the tutorial project repo. Next, include the Firestore library byadding the following to your Podfile:
pod 'Firebase/Core' pod 'Firebase/Firestore'
Enter the following in your terminal to build your library:
pod install
Next, switch to Xcode and open up the .xcworkspace file. Navigate to the AppDelegate.swift file and enter the following within the application:didFinishLaunchingWithOptions:
method:
FirebaseApp.configure()
In your browser, go to the Firebase console and select the Database tab on the left.
Make sure you select the option to Start in Test Mode so that you don’t have any security issues while we experiment, and heed the security notice when you do move your app into production. You are now ready to create a collection and some sample documents.
Adding a Collection and Sample Document
To start off, create an initial collection, Tasks
, by selecting the Add Collection button and naming the collection, as illustrated below:
For the first document, you are going to leave the Document ID blank, which will auto-generate an ID for you. The document will simply consist of two fields: name
and done
.
Save the document, and you should be able to confirm the collection and document along with the auto-generated ID:
With the database set up with a sample document in the cloud, you are ready to start implementing the Firestore SDK in Xcode.
Creating & Working With Database References
Open up the MasterViewController.swift file in Xcode and add the following lines to import the library:
import Firebase class MasterViewController: UITableViewController { @IBOutlet weak var addButton: UIBarButtonItem! private var documents: [DocumentSnapshot] = [] public var tasks: [Task] = [] private var listener : ListenerRegistration! ...
Here you are simply creating a listener variable that will allow you to trigger a connection to the database in real time when there is a change. You are also creating a DocumentSnapshot
reference that will hold the temporary data snapshot.
Before continuing with the view controller, create another swift file, Task.swift, which will represent your data model:
import Foundation struct Task{ var name:String var done: Bool var id: String var dictionary: [String: Any] { return [ "name": name, "done": done ] } } extension Task{ init?(dictionary: [String : Any], id: String) { guard let name = dictionary["name"] as? String, let done = dictionary["done"] as? Bool else { return nil } self.init(name: name, done: done, id: id) } }
The code snippet above includes a convenience property (dictionary) and method (init) that will make populating the model object easier. Switch back to the view controller, and declare a global setter variable which will constrain the base query to the top 50 entries in the tasks list. You will also be removing the listener once you set the query variable, as denoted in the didSet
property below:
fileprivate func baseQuery() -> Query { return Firestore.firestore().collection("Tasks").limit(to: 50) } fileprivate var query: Query? { didSet { if let listener = listener { listener.remove() } } } override func viewDidLoad() { super.viewDidLoad() self.query = baseQuery() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.listener.remove() }
Reading Data in Real Time From Cloud Firestore
With the document reference in place, in viewWillAppear(_animated: Bool)
, associate the listener you created earlier with the results of the query snapshot, and retrieve a list of documents. This is done by calling the Firestore method query?.addSnapshotListener
:
self.listener = query?.addSnapshotListener { (documents, error) in guard let snapshot = documents else { print("Error fetching documents results: \(error!)") return } let results = snapshot.documents.map { (document) -> Task in if let task = Task(dictionary: document.data(), id: document.documentID) { return task } else { fatalError("Unable to initialize type \(Task.self) with dictionary \(document.data())") } } self.tasks = results self.documents = snapshot.documents self.tableView.reloadData() }
The closure above assigns the snapshot.documents
by mapping the array iteratively and wrapping it to a new Task
model instance object for each data item in the snapshot. So with just a few lines, you have successfully read in all the tasks from the cloud and assigned them to the global tasks
array.
To display the results, populate the followingTableView
delegate methods:
override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return tasks.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) let item = tasks[indexPath.row] cell.textLabel!.text = item.name cell.textLabel!.textColor = item.done == false ? UIColor.black : UIColor.lightGray return cell }
At this stage, build and run the project and in the Simulator you should be able to observe data appearing in real time. Add data via the Firebase console and you should see it appear instantaneously in the app simulator.
Creating, Updating and Deleting Data
After successfully reading content from the back-end, next you will create, update and delete data. The next example will illustrate how to update data, using a contrived example where the app will only let you mark an item as done by tapping on the cell. Note the collection.document(
item.id
).updateData(["done": !item.done])
closure property, which simply references a specific document ID, updating each of the fields in the dictionary:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let item = tasks[indexPath.row] let collection = Firestore.firestore().collection("Tasks") collection.document(item.id).updateData([ "done": !item.done, ]) { err in if let err = err { print("Error updating document: \(err)") } else { print("Document successfully updated") } } tableView.reloadRows(at: [indexPath], with: .automatic) }
To delete an item, call the document(
item.id
).delete()
method:
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { if (editingStyle == .delete){ let item = tasks[indexPath.row] _ = Firestore.firestore().collection("Tasks").document(item.id).delete() } }
Creating a new task will involve adding a new button in your Storyboard and connecting its IBAction
to the view controller, creating an addTask(_ sender:)
method. When a user presses the button, it will bring up an alert sheet where the user can add a new task name:
collection("Tasks").addDocument (data: ["name": textFieldReminder.text ?? "empty task", "done": false])
Complete the final part of the app by entering the following:
@IBAction func addTask(_ sender: Any) { let alertVC : UIAlertController = UIAlertController(title: "New Task", message: "What do you want to remember?", preferredStyle: .alert) alertVC.addTextField { (UITextField) in } let cancelAction = UIAlertAction.init(title: "Cancel", style: .destructive, handler: nil) alertVC.addAction(cancelAction) //Alert action closure let addAction = UIAlertAction.init(title: "Add", style: .default) { (UIAlertAction) -> Void in let textFieldReminder = (alertVC.textFields?.first)! as UITextField let db = Firestore.firestore() var docRef: DocumentReference? = nil docRef = db.collection("Tasks").addDocument(data: [ "name": textFieldReminder.text ?? "empty task", "done": false ]) { err in if let err = err { print("Error adding document: \(err)") } else { print("Document added with ID: \(docRef!.documentID)") } } } alertVC.addAction(addAction) present(alertVC, animated: true, completion: nil) }
Build and run the app once more and, when the simulator appears, try adding in a few tasks, as well as marking a few as done, and finally test the delete function by removing some tasks. You can confirm that the stored data has been updated in real time by switching over to your Firebase database console and observing the collection and documents.
Filtering and Compound Queries
So far, you've only worked with a simple query, without any specific filtering capabilities. To create slightly more robust queries, you can filter by specific values by making use of a whereField
clause:
docRef.whereField(“name”, isEqualTo: searchString)
You can order and limit your query data, by making use of the order(by: )
and limit(to: )
methods as follows:
docRef.order(by: "name").limit(5)
In the FirebaseDo app, you already made use of limit
with the base query. In the above snippet, you also made use of another feature, compound queries, where both the order and limit are chained together. You can chain as many queries as you want, such as in the following example:
docRef .whereField(“name”, isEqualTo: searchString) .whereField(“done”, isEqualTo: false) .order(by: "name") .limit(5)
Conclusion
In this tutorial, you explored Google’s new MBaaS product, Cloud Firestore, and in the process created a simple task reminder app that demonstrates how easy it is for you to persist, synchronize, and query your data in the cloud. You learned about Firestore’s data schema structure in comparison to Firebase Realtime Database, and how to read and write data in real time, as well as updating and deleting data. You also learned how to perform simple as well as compound queries, and how to filter data.
Cloud Firestore was created with the aim of providing the robustness of Firebase Realtime Database without many of the limitations mobile developers had to endure, especially as pertains to scalability and querying. We only scratched the surface of what you can accomplish with Firestore, and it's certainly worth exploring some of the more advanced concepts, such as Paginating Data with Query Cursors, Managing Indexes, and Securing Your Data.