Goals

In this extended codelab, you will build a restaurant recommendation app powered by Cloud Firestore on iOS with Swift.

Over the course of the codelab, you will learn how to:

Prerequisites

Before starting this workshop, please make sure you have installed:

All of the code in this workshop and sample app uses Swift 4.0.

Download the Code

Begin by cloning the sample project.

git clone https://github.com/firebase/firestore-codelab-extended-swift
cd firestore-codelab-extended-swift

You might notice that we've already gone through the trouble of installing the appropriate Cocoapod libraries for you. Feel free to look at the Podfile to see exactly what's been installed.

Open FriendlyEats.xcworkspace in Xcode and run it (Cmd+R). The app should compile correctly and immediately crash on launch, since it's missing a GoogleService-Info.plist file. We'll correct that in the next step.

Set up Firebase

Go to the Firebase Console and click the "Add Project" button to create a new project. Give it whatever project name you want, and select the Country or Region that's appropriate for you.

Once your project has been created, click the "Add Firebase to your iOS app" button to connect your iOS project to your app, and fill out the fields in the first screen. Make sure you have the bundle ID copied exactly from your Xcode project. Then click Register App.

Download the GoogleService-Info.plist file and drag it into your Xcode project. Note that this file name must match exactly. If you end up with a name like GoogleService-Info (2).plist because of duplicate files in your downloads folder, you'll need to rename it to the original file name.

When prompted, check "Copy items if needed".

You can ignore steps 3 (Add Firebase SDK) and 4 (Add initialization code) of the setup instructions, because they've already been done in your sample project.

Finally, run the app once more to verify everything is properly set up. The app should load up an empty table view at launch without crashing.

Enable Email / Password Authentication

Firebase Authentication supports a number of different authentication methods, from Google and Facebook sign-in to Phone Number authentication to plain old email/password sign-in.

This project uses email/password authentication. To enable this sign-in method, go to the Authentication section of the Firebase Console, click the Sign-in Method tab, and then enable Email/Password. (We're not going to be using the passwordless sign-in method in this codelab, so feel free to leave it disabled.)

Enable Cloud Firestore

Because Cloud Firestore is still in beta, it is not enabled by default. To enable it, select the Database section of the Firebase console, then click on the Create Database button.

In the dialog that appears next, select "Start in test mode". This will make any part of your database readable and writable by any client. This is a terrible idea from a security standpoint, and you should never publish your app in this setting, but it makes it easy to get up and running with some sample code.

We'll investigate how to make your database more secure with stricter security rules in a later section of this codelab.

Enable Cloud Storage for Firebase

Cloud Storage for Firebase is a service specifically designed for serving up large binary objects such as images. While we don't go into a lot of detail about Cloud Storage in this codelab, we'll be using it to store and retrieve user-uploaded images.

To enable Cloud Storage, go to the Storage section of the Firebase Console, and click the Get Started button.

Build and Run

Build and run your app again. Now you should have the ability to sign in or create a new account, and you'll be able to see a list of restaurants by clicking the Restaurants tab at the bottom.

As it turns out, this screen will be empty because there's currently no data in our database, but you get to see Sparky, our mascot. Hi, Sparky!

Let's add some data in the next section.

Populate the database

We'll learn how to add data to the database in the next couple of sections, but for now let's quickly add a bunch of sample data into Cloud Firestore so we can explore it and better understand how a document-model NoSQL database works.

Within the app on the device, click the Restaurants tab. Then click the Populate button in the upper-left. Click on "Yes" to proceed with adding sample data to the database. The code that is doing all of this work is hidden away in the Firestore+Populate.swift extension, but don't worry about trying to understand all of that code yet.

After a brief delay, you should see the line Batch committed! in the Xcode console. There won't be any restaurants on FriendlyEats's screen because we haven't implemented fetching data from Firestore yet. We'll get to that soon, but first let's take a look at the data we'll be fetching.

Explore the data

Head back to the Database section of the Firebase console. You should now see something that looks like this.

Let's go over what you're looking at here.

Documents and Collections

All data in Cloud Firestore is stored as a Document. You can think of a document as something like a [String: Any] dictionary. All items are stored in fields, which are essentially key-value pairs. The keys are strings, and the values can be anything from strings to integers to small binary blobs to smaller JSON-like objects called maps.

If you take a look at one of the Restaurant documents, you can see these fields in action.

All documents are organized into groups called Collections. You can think of a collection as a [String: Document] dictionary.

In your database, you have three top-level collections: restaurants, reviews, and users.

Collections contain nothing but documents; you cannot store data directly into a collection. Similarly, documents cannot contain other documents, but they point to subcollections, which in turn contain other documents. So it's quite common in a production app to see a collection which contains a number of different documents, which then point to other subcollections, and so on.

Click on the reviews collection to start exploring some of the documents in there. You'll notice several interesting qualities about these documents:

First, all reviews have a userInfo field containing some information about the user who wrote the review. It's a map; that is, it contains a smaller number of key-value pairs. One nice aspect about containing data within maps like this is that you can still query your data based on any of these individual field values. That is, you can create a query like, "Select all reviews where userInfo.userID == 'abcdefg'" and that will work just fine.

Second, some reviews have a subcollection called yums. In our app, we're going to let people "yum" a review to show their approval of a review. This subcollection consists of a single piece of data (the name of the user) and the ID of this document is the ID of the user who originally "yummed" the review. We'll use this data later on in our app to make sure that a user can't yum a review more than once.

Don't worry if the yum subcollection seems confusing right now; it will be explained in detail later on.

Denormalized Data

Finally, you might notice that there's some redundant data in your review documents. For example, every review contains the name of the restaurant along with its ID. And they contain the name and URL of the reviewer itself, not just the userID.

This might seem strange to those of you coming from a SQL database, where you would expect that, for instance, your review would only contain the ID of the restaurant, and then you would use a SQL query to join the information from a review table with whatever information you might need from the restaurant and user table.

But Cloud Firestore is a NoSQL database, and one important characteristic of NoSQL databases is that -- and this might shock you -- there's no SQL! This means that the kind of querying that you can run against your database is much simpler.

In Cloud Firestore, for instance, you can only run queries that retrieve certain documents within a collection. You can't join data with documents in other collections, or perform other server-side calculations. This make queries incredibly fast, but as a result, it means that you'll often be working with denormalized data -- redundant data that exists in multiple places.

This has implications when you want to make changes to your data. If a user decides to change their display name, you will also want to change the userInfo.displayName field for every review they've ever written. You'll see how to handle this exact situation later in this workshop.

This is the same reason why fields that might normally be calculated in a SQL database (for example, the average review score for a restaurant, or the number of yums that a review received) are pre-calculated in a NoSQL database.

Why aren't reviews a subcollection of restaurants?

So if "yum"s are a subcollection of reviews, why aren't reviews a subcollection of restaurants? The truth is, they could be, and depending on what we want our restaurant review app to do, it might be the better option.

But as you will see later on in the workshop, there are trade-offs between these two different database setups, particularly when it comes to what kinds of data you can fetch from a specific query. We'll talk a little more about this in a later section.

Now that we've got data in Firestore, let's write some code to display it in our app. To fetch data from Firestore, we'll need a query. Queries are extremely lightweight objects that Firestore uses to reference data locations. Since they're so cheap, they're immutable and we'll be creating (and destroying) a lot of them over the course of this codelab.

Basic Queries

Here's an example of a basic query in Cloud Firestore:

Firestore.firestore().collection("restaurants").limit(to: 50)

Let's take a look at what's in this declaration.

Running a Simple Query

Let's take a look at what this query does when you run it. There are two common actions you will perform on a query: getDocuments() will perform the query once and return the results in a completion handler. addSnapshotListener(_:) will perform the query and run the completion handler the very first time, and also whenever data changes on the database.

We'll talk more about addSnapshotListener(_:) shortly for some real-timey awesomeness, but let's start by just running a simple getDocuments call.

Open up BasicRestaurantsTableViewController and add the following code to the tryASampleQuery() method:

let basicQuery = Firestore.firestore().collection("restaurants").limit(to: 3)
basicQuery.getDocuments { (snapshot, error) in
  if let error = error {
    print("Oh no! Got an error! \(error.localizedDescription)")
    return
  }
  guard let snapshot = snapshot else { return }
  let allDocuments = snapshot.documents
  for restaurantDocument in allDocuments {
    print("I have this restaurant \(restaurantDocument.data())")
  }
}

A snapshot is an object that represents the results of the query, and usually you will access its documents property to retrieve the documents that it contains. A document represents a single document in our database. By calling the data()method on a document, you can retrieve all of its fields as a [String: Any] dictionary.

If you run your app now, you'll probably see some output like this:

This is a good start, but let's deal with our restaurant data in a more strongly-typed way. For ease of use, we've already built a Restaurant struct for you. One of its initializers takes in a document object, grabs the data from that document, and returns a valid Restaurant if it's able to parse the data correctly. Try updating your sample call to convert these incoming documents to Restaurants:

let basicQuery = Firestore.firestore().collection("restaurants").limit(to: 3)
basicQuery.getDocuments { (snapshot, error) in
  if let error = error {
    print("Oh no! Got an error! \(error.localizedDescription)")
    return
  }
  guard let snapshot = snapshot else { return }
  let allDocuments = snapshot.documents
  for restaurantDocument in allDocuments {
    print("I have this restaurant \(restaurantDocument.data())")
    // Add these new lines here
    if let newRestaurant = Restaurant(document: restaurantDocument) {
      print("Restaurant \(newRestaurant.name) has an average score of \(newRestaurant.averageRating)")
    }
  }
}

If you run your app again, you should now see some output like this:

Now that you've seen how you can load up data from Cloud Firestore, let's start using it to populate our table view.

Displaying data in real-time

If you look at our BasicRestaurantsTableViewController, you can see that it's already set up to use resturantData, an array of Restaurant structs, as its model. So all we need to do is load our data from Cloud Firestore into that array, call tableView.reloadData() and we'll have successfully populated our table view.

But rather than doing this in a simple fetch like above, we're going to use a snapshotListener. A snapshotListener works similar to a getDocuments call, in that the first time you run it, it will grab all the data in the collection. But it will also call its completion handler with updated data any time data changes on the backend. By taking advantage of this listener, we can have an app that updates in near real-time with essentially the same amount of work as running a simple fetch.

To get this started, add the following code to startListeningForRestaurants()

let basicQuery = Firestore.firestore().collection("restaurants").limit(to: 50)
restaurantListener = basicQuery.addSnapshotListener { (snapshot, error) in
  if let error = error {
    print ("I got an error retrieving restaurants: \(error)")
    return
  }
  guard let snapshot = snapshot else { return }
  self.restaurantData = []
  for restaurantDocument in snapshot.documents {
    if let newRestaurant = Restaurant(document: restaurantDocument) {
      self.restaurantData.append(newRestaurant)
    }
  }
  self.tableView.reloadData()
}

This should look nearly the same as the sample query you created above. The only differences are...

As a general rule, when you create a listener in Cloud Firestore, you'll want to deactivate it when it's no longer needed. If you create your listener during viewWillAppear, for example, it's generally considered appropriate to then deactivate it during viewWillDisappear. This helps make sure your application isn't using more data (or battery life) than necessary, and also avoids some retain cycles.

By assigning the results of addSnapshotListener to a ListenerRegistration object, you now have a reference to your listener, so you can tell it when to stop listening to updates.

We've already created the stopListeningForRestaurants function for you. You just need to fill it out

private func stopListeningForRestaurants() {
  restaurantListener?.remove()
  restaurantListener = nil
}

Finally, all that's left to do here is make sure your table view cell knows how to populate itself when given a Restaurant struct. Open up RestaurantTableViewCell and fill out the populate(restaurant:) method:

func populate(restaurant: Restaurant) {
  nameLabel.text = restaurant.name
  cityLabel.text = restaurant.city
  categoryLabel.text = restaurant.category
  starsView.rating = Int(restaurant.averageRating.rounded())
  priceLabel.text = Utils.priceString(from: restaurant.price)
  thumbnailView.sd_setImage(with: restaurant.photoURL)
}

Run the app and navigate over to the Restaurants tab. If everything went well, you should see a list of restaurants from the restaurants collection.

But now for something more impressive: Go back to Firebase console, click on one of the restaurant objects, and change its name. Then go back to your app, and its name should be updated nearly immediately. It's like magic!

What we just created works fine in a simple application, but there are two reasons you might want to refactor this a bit.

  1. There are actually two places where your app will have a table view that needs to be populated with Restaurant data: The page you just created and the "My Restaurants" section of the user profile.
  2. It might be nice if you separated your "Grab data from Cloud Firestore" logic from the rest of the presentation logic.

And so in the interest of writing some cleaner code, we're going to use a RestaurantTableViewDataSource object. This object will take in a basic Cloud Firestore query, as well as a handler to call when it has new data. This object will then perform the work of creating the snapshotListeners, populating the Restaurant array, and performing all of the usual UITableViewDataSource work.

With all of that in mind, a look at RestaurantTableViewDataSource.swift.

@objc class RestaurantTableViewDataSource: NSObject, UITableViewDataSource {

  var restaurants: [Restaurant] = []

  public init(query: Query,
              updateHandler: @escaping ([DocumentChange]) -> ()) {
    fatalError("Unimplemented")
  }

  // Pull data from Firestore

  /// Starts listening to the Firestore query and invoking the updateHandler.
  public func startUpdates() {
    fatalError("Unimplemented")
  }

  /// Stops listening to the Firestore query. updateHandler will not be called unless startListening
  /// is called again.
  public func stopUpdates() {
    fatalError("Unimplemented")
  }

  /// Returns the restaurant at the given index.
  subscript(index: Int) -> Restaurant {
    return restaurants[index]
  }

  /// The number of items in the data source.
  public var count: Int {
    return restaurants.count
  }

  // MARK: - UITableViewDataSource

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "RestaurantTableViewCell",
                                             for: indexPath) as! RestaurantTableViewCell
    let restaurant = restaurants[indexPath.row]
    cell.populate(restaurant: restaurant)
    return cell
  }
}

You'll notice the data source methods are already filled in, and they don't really do anything special. This is a good thing--this class should only be responsible for one major task, and that's downloading data from Firestore and keeping it in a collection.

In order for our data source to work, it's going to need two things: a) The query that it should run to start looking for data, and b) The callback that it should call when it receives new data.

We'll store both of these as properties, and pass them along to the initializer. Add these lines to the beginning of the RestaurantTableViewDataSource class:

private let query: Query
private var listener: ListenerRegistration?
private let updateHandler: ([DocumentChange]) -> ()

And implement the initializer:

public init(query: Query,
            updateHandler: @escaping ([DocumentChange]) -> ()) {
  self.query = query
  self.updateHandler = updateHandler
}

Now we have all the building blocks for our UI, all we have to do is connect them together. Fill out the startUpdates() and stopUpdates() methods like so:

/// Starts listening to the Firestore query and invoking the updateHandler.
public func startUpdates() {
  guard listener == nil else { return }
  listener = query.addSnapshotListener { [unowned self] (querySnapshot, error) in
    guard let snapshot = querySnapshot else {
      if let error = error {
        print("Error fetching snapshot results: \(error)")
      } else {
        print("Unknown error fetching snapshot data")
      }
      return
    }
    let models = snapshot.documents.map { (document) -> Restaurant in
      if let model = Restaurant(document: document) {
        return model
      } else {
        // handle error
        fatalError("Unable to initialize Restaurant with dictionary \(document.data())")
      }
    }
    self.restaurants = models
    self.updateHandler(snapshot.documentChanges)
  }
}

Most of this should look very familiar to the work you did in the previous section. It's adding a snapshot listener that, when it receives data, populates the restaurants array that will serve as the backing collection behind our data source. It then calls its update handler to let any of its consumers know that it has new data.

The documentChanges property is a list of everything that's changed since the real-time listener last fired. Depending on how sophisticated your table view is, you might want to perform specialized logic depending on exactly what's changed in the data (for example, adding different visual effects for data that's added or deleted). In our case, however, we'll be ignoring this value and just reloading the entire table view.

While you're at it, go ahead and implement stopUpdates(), which deactivates our listener if it exists.

/// Stops listening to the Firestore query. updateHandler will not be called unless startListening
/// is called again.
public func stopUpdates() {
  listener?.remove()
  listener = nil
}

Using Our New Data Source

Now that our data source is complete, we can plug it into our table views and they'll work automatically, as if by magic. Reusing a small component like this is also a good way of reducing bugs in your code, and if there are any Firestore bugs, they'll be isolated to this specific component and you'll know where to look for them when they appear.

First, go into your storyboard, and find the first Restaurants Scene object in the outline. (It should be listed second in the outline.) Open up the Identity Inspector in the Utilities panel and change the class from BasicRestaurantsTableViewController to RestaurantsTableViewController.

Open up RestaurantsTableViewController.swift and make use of our new data source component. This class is similar to the BasicRestaurantsTableViewController class you were using earlier, but it has some additional logic for displaying some filtering UI, which you'll make use of later.

As we mentioned before, the only thing our controller is concerned about is updating the table view in response to new data, and the data source should take care of everything else.

So first off, let's define the basic query we're going to want our data source to use. In RestaurantsTableViewController.swift, replace the fatalError() in the baseQuery declaration with the following:

private lazy var baseQuery: Query = {
  return Firestore.firestore().collection("restaurants").limit(to: 50)
}()

Next up, we're going to fill out the lazy dataSource property. This uses a helper function that takes in some kind of Cloud Firestore Query and returns a DataSource in the process:

lazy private var dataSource: RestaurantTableViewDataSource = {
  return dataSourceForQuery(baseQuery)
}()

Now, implement the dataSourceForQuery(_:) function. In this code, we're making use of the updateHandler we wrote earlier to call tableView.reloadData() whenever there's new data.

private func dataSourceForQuery(_ query: Query) -> RestaurantTableViewDataSource {
  return RestaurantTableViewDataSource(query: query) { [unowned self] (changes) in
    if self.dataSource.count > 0 {
      self.tableView.backgroundView = nil
    } else {
      self.tableView.backgroundView = self.backgroundView
    }

    self.tableView.reloadData()
  }
}

The query here is also conveniently parameterized, so we can get a new data source if we ever need to modify the query and change the contents of the table view.

In fact, if you peek at query's didSet observer, you can see that when you define a new query parameter, we essentially clean up and remove the old data source, and then create a brand new data source with our new query. This will come in handy later when we implement filtering and sorting.

Finally, go ahead and assign the data source in viewDidLoad():

tableView.dataSource = dataSource

And don't forget to start and stop listening to updates on the query in viewWillAppear() and viewWillDisappear().

override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)
  self.setNeedsStatusBarAppearanceUpdate()
  dataSource.startUpdates()
}

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  dataSource.stopUpdates()
}

Run the app again. If everything went well, it should work just like before, but our code smells a little nicer.

Minty fresh!

Right now, our query for fetching restaurant data from Cloud Firestore simply consists of grabbing all documents in the Restaurant collection and stopping when we get to 50. But we can get a little more sophisticated than that, by adding filters to our queries.

In general, you need to follow these rules when querying for data in Cloud Firestore:

We'll go into more detail about some of these rules later, but let's put this into practice right now.

Show reviews for a restaurant

When a user clicks on a restaurant, we're going to want our app to bring up a list of all reviews for that restaurant. If you look at a typical Review document in the database, you can see that it has a restaurantID field which is the ID of the restaurant this review is for:

So if you think about it, showing all the reviews for a restaurant just means filtering our entire Reviews collection for documents where the restaurantID field of the review is equal to the ID of the restaurant we're looking at.

Head over to RestaurantDetailViewController.swift -- you can see that, like our RestaurantsTableViewController, it has a custom Data Source (in this case, a ReviewTableViewDataSource) that is defined by some kind of query.

Fill in the baseQuery property as follows:

lazy private var baseQuery: Query = {
  Firestore.firestore().collection("reviews")
      .whereField("restaurantID", isEqualTo: restaurant.documentID)
}()

Because we've designed our data sources to be agnostic of the queries they're using, adding filters and other modifiers doesn't change how we display Firestore data in our views, it only changes which subsets of the data get displayed.

All we have to do is take the data from Firestore and populate the fields in our table view cell. In ReviewTableViewCell.swift, fill in the populate(review:) and the helper showYumText() methods:

func populate(review: Review) {
  self.review = review
  restaurantNameLabel?.text = review.restaurantName
  usernameLabel?.text = review.userInfo.name
  userIcon?.sd_setImage(with: review.userInfo.photoURL)
  starsView.rating = review.rating
  reviewContentsLabel.text = review.text
  showYumText()
}

func showYumText() {
  switch review.yumCount {
  case 0:
    yumsLabel.isHidden = true
  case 1:
    yumsLabel.isHidden = false
    yumsLabel.text = "1 yum"
  default:
    yumsLabel.isHidden = false
    yumsLabel.text = "\(review.yumCount) yums"
  }
}

Then run the app again, and tap through to a restaurant. You should be able to see some reviews under the restaurant title.

Show reviews for a user

Next up, we want the ProfileViewController to show our user all of the reviews that they've written. This works very similar to our other queries, but this we're going to be filtering the reviews collection by the userInfo.userID field.

In the ProfileViewController, complete the populateReviews(forUser:) method like so:

fileprivate func populateReviews(forUser user: User) {
  let query = Firestore.firestore().reviews.whereField("userInfo.userID", isEqualTo: user.userID)
  dataSource = ReviewTableViewDataSource(query: query) { [unowned self] (changes) in
    self.tableView.reloadData()
    guard let dataSource = self.dataSource else { return }
    if dataSource.count > 0 {
      self.tableView.backgroundView = nil
    } else {
      self.tableView.backgroundView = self.tableBackgroundLabel
    }
  }
  dataSource?.sectionTitle = "My reviews"
  dataSource?.startUpdates()
  tableView.dataSource = dataSource
}

Note that Firestore allows you to filter based on individual fields in those JSON-like map fields. That is, we can filter by "userInfo.userID" as well as fields like "restaurantID" or "rating". Other than that, this logic is pretty similar to the last section.

Show restaurants for a user

Later on, we'll be giving our users the ability to create restaurants as well. If you open up MyRestaurantsViewController, you can see that we've added all of the logic into a few lines in our viewWIllAppear() method by looking for restaurants where the ownerID field is equal to our current user's ID. Because this class is making use of the RestaurantTableViewDataSource object, there's very little else our class needs to do to populate the table on this screen.

Run the app again and take a look at the User tab. Sadly, in spite of all the work we did, the list of reviews and the "My Restaurants" screen should still be empty, because we haven't implemented a way for you to create those yet. We'll get to that shortly.

Structuring The Database, Revisited

This might be a good time to revisit our earlier database decision. Why we choose to put our reviews into a top-level collection, instead of as a subcollection of each restaurant?

Well, it turns out both of these structures make it easy for a user to grab reviews for a specific restaurant. As a subcollection, you would just grab all documents from a specific subcollection, but it's just as easy (and fast) to get these documents by filtering from a top-level collection.

The big difference is that if you wanted to fetch all reviews written by a specific author, that's not something you can do with the subcollection model, because that would involve grabbing documents from different collections. On the other hand the top-level collection makes it easy to grab all reviews written by a specific author.

That doesn't mean that our solution is always the right one. In theory, you could get around this problem by denormalizing more data -- for instance, every "User" document might contain a map field with snippets of all of the reviews they've written. And there are some disadvantages to putting too many documents into one subcollection which we will touch on later. But if we assume that "Showing a user all of the reviews they've written" is something we're going to do frequently in our app, then our current database solution makes the most sense.

More Refactoring?

Before we move onto writing data, you may have noticed that the ReviewTableViewDataSource and RestaurantTableViewDataSource classes function nearly identically, and you may have suspected there's some duplicated code between the two. It turns out this is true! If you look closely at the data source classes, they both must collect data from Firestore and serialize it to a type before vending it to a table view.

In ReviewTableViewDataSource.swift this has been factored out as another dependency, a generic type called LocalCollection that's parameterized by any struct type that can be serialized from Firestore.

As an optional exercise, try refactoring RestaurantTableViewDataSource to use LocalCollection instead of an array. There's no code blocks here to help you out since this is an optional exercise, but if you'd like to check the correctness of your solution, check out the codelab-complete branch.

So we've already created queries that can grab all documents from the Restaurants collection:

let query = Firestore.firestore().collection("restaurants").limit(to: 50)

And we've also created queries that filter for restaurants belonging to a specific user:

let query = Firestore.firestore().restaurants
    .whereField("ownerID", isEqualTo: user.userID)

But there's more we can do with these queries! For example, this one will find up to fifty coffee shops:

let query = Firestore.firestore().collection("restaurants")
    .whereField("category", isEqualTo: "Coffee").limit(to: 50)

This query will find restaurants with an average rating over 3.2:

let query = Firestore.firestore().collection("restaurants")
    .whereField("averageRating", isGreaterThan: 3.2).limit(to: 50)

This query will find only coffee shops with a `$$$` price rating:

let query = Firestore.firestore().collection("restaurants")
    .whereField("category", isEqualTo: "Coffee").whereField("price", isEqualTo: 3)

This final query will find your highest rated restaurants:

let query = Firestore.firestore().collection("restaurants")
    .order(by: "averageRating", descending: true).limit(to: 50)

Note that this last query won't actually filter any of our results -- it orders them by the value of a specific field. That's also something we can do in Cloud Firestore.

With that in mind, we have just about everything to need to build some nice filtering features into our restaurant app.

Sorts and Filters in practice

Take a look at the FiltersViewControllerDelegate declaration at the bottom of FiltersViewController.swift.

protocol FiltersViewControllerDelegate: NSObjectProtocol {

  func controller(_ controller: FiltersViewController,
                  didSelectCategory category: String?,
                  city: String?, price: Int?, sortBy: String?)

}

FiltersViewController is fundamentally just a way for the user to specify what query we want to run. In RestaurantsTableViewController.swift, we're going to use that data to change which restaurants are displayed and the order they're displayed in.

You may have noticed, though, that Firestore queries are immutable. Since they're simply small value types (strings) glued together, they're treated as value types as well. So we won't be able to apply the sorts just by modifying the query itself--instead we'll have to modify the query property of the controller. This has already been done for you in the implementation of the query property.

fileprivate var query: Query? {
  didSet {
    dataSource.stopUpdates()
    tableView.dataSource = nil
    if let query = query {
      dataSource = dataSourceForQuery(query)
      tableView.dataSource = dataSource
      dataSource.startUpdates()
    }
  }
}

Now we can create a query based on the fields returned by the FiltersViewController. Fill out the query(withCategory:city:price:sortBy:) method as follows:

func query(withCategory category: String?, city: String?, price: Int?, sortBy: String?) -> Query {

  if category == nil && city == nil && price == nil && sortBy == nil {
    stackViewHeightConstraint.constant = 0
    activeFiltersStackView.isHidden = true
  } else {
    stackViewHeightConstraint.constant = 44
    activeFiltersStackView.isHidden = false
  }

  var filtered = baseQuery

  // Sort and Filter data

  if let category = category, !category.isEmpty {
    filtered = filtered.whereField("category", isEqualTo: category)
  }

  if let city = city, !city.isEmpty {
    filtered = filtered.whereField("city", isEqualTo: city)
  }

  if let price = price {
    filtered = filtered.whereField("price", isEqualTo: price)
  }

  if let sortBy = sortBy, !sortBy.isEmpty {
    filtered = filtered.order(by: sortBy)
  }

  return filtered
}

The only new part of this code is the order(by:) call in the last if-statement, and it is as straightforward as it sounds.

Because of how we've set up our app, all we have to do is transform the data from the FiltersViewController into a query and then pass that query to our controller and everything will magically work. This function just returns the new query; you can find the assignment logic in the implementation of the protocol method mentioned earlier.

Indexes

Run the app again and try applying some sorts and filters. You'll notice that if you're only applying one sort or filter at a time, everything works correctly, but as soon as you try to add multiple filters Firestore will print an error in console that looks like this.

Error fetching snapshot results: Error Domain=FIRFirestoreErrorDomain Code=9 "The query requires an index. You can create it here: https://console.firebase.google.com/project/testapp-5d356/database/firestore/indexes?create_index=EgtyZXN0YXVyYW50cxoMCghjYXRlZ29yeRACGhEKDWF2ZXJhZ2VSYXRpbmcQAhoMCghfX25hbWVfXxAC" UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/testapp-5d356/database/firestore/indexes?create_index=EgtyZXN0YXVyYW50cxoMCghjYXRlZ29yeRACGhEKDWF2ZXJhZ2VSYXRpbmcQAhoMCghfX25hbWVfXxAC}

Paste the link you receive in that error into your browser, which will take you to a page in Firestore console to create an index.

Go ahead and create any indexes that Firestore tells you to create, and while they're building, we can talk about how queries behave in Firestore and why you need composite indexes.

Understanding Indexes

Firestore queries are very fast. They scale with the size of the result set and not the total data set, so a query returning a hundred documents takes the same amount of time, whether you're searching through a thousand documents or a million. Cloud Firestore does this through the use of indexes -- essentially, you can't query something unless it's been indexed.

Luckily, Cloud Firestore automatically indexes every field in every document you put in a collection. For example, finding all Pizza restaurants in our Restaurant collection is easy and fast because Cloud Firestore creates an index for all values of our "category" field.

Similarly, fetching all restaurants sorted by price is also very quick and easy in Cloud Firestore because that value is also in our index.

These queries all work quickly, because they generally follow the strategy of "Find a value in the index, then gather all adjacent records".

But if you want to search for a specific value in one field and also perform a greater-than or less-than search in another field, that won't work because there's no way to perform that search in a single index. You'd have a similar problem if you wanted to perform an equality search in one field and sort those results by another field. So you can't find all Pizza restaurants and sort them by price, for example.

This is where a composite index can help. It will essentially create an automatically-maintained field that combines the values together from these two different fields. So now finding all pizza restaurants sorted by price is possible... if you have a category and price composite index set up.

While you can create these composite indexes manually, often it's easiest to just wait until you try to perform a search that Cloud Firestore can't perform with its current set of indexes, and then follow the URL in the error message, like you did earlier.

Once your composite index has finished building, try sorting and filtering again to confirm that the errors no longer appear.

Congrats, you've learned how to query data from Firestore! Read on to find out how to write data to the backend.

Write New Documents

Now we get to start writing data to Firestore! Let's take a look at the most basic possible write. In ProfileViewController.swift, add the following code under the self.user assignment like so:

fileprivate func setUser(firebaseUser: FirebaseAuth.UserInfo?) {
  if let firebaseUser = firebaseUser {
    let user = User(user: firebaseUser)
    self.user = user
    // Add these lines here
    Firestore.firestore()
        .collection("users")
        .document(user.userID)
        .setData(user.documentData) { error in
          if let error = error {
            print("Error writing user to Firestore: \(error)")
          }
        }
  } else {
    user = nil
  }
}

The only new method in this code, setData(_:_:), does exactly what its name says. If there's no existing data at that location, it creates a new document. If the collection housing the document doesn't exist either, that gets created too. Otherwise, it overwrites the document that exists at that location. Also, note the types of user.userID and user.documentData, whose implementations can be found in User.swift.

This is a convenient way to sync users to our database. Since we're using each user's unique identifier as a document ID, we won't have any collisions on the server where users are overwriting other users' data.

Now let's look at adding a new document to a collection. Head over to AddRestaurantViewController.swift and add the following to the bottom of the saveChanges() method.

func saveChanges() {

  /* ... */

  print("Going to save document data as \(restaurant.documentData)")

  Firestore.firestore().collection("restaurants").document()
      .setData(restaurant.documentData) { error in
        if let error = error {
          print("Error writing document: \(error)")
        } else {
          self.presentDidSaveAlert()
        }
  }
}

We're using setData(_:_:) here as well, but unlike the last write, our call to document() doesn't take any arguments. In this case, Firestore will generate a document ID pointing to a new document in the collection. This is the easiest way to add documents to a collection, especially if you don't care about what the document's ID will ultimately be.

Adding reviews follows almost exactly the same pattern. In NewReviewViewController.swift's doneButtonPressed(_:) method:

@IBAction func doneButtonPressed(_ sender: Any) {
  // TODO: handle user not logged in.
  guard let user = Auth.auth().currentUser.flatMap(User.init) else { return }
  let review = Review(restaurantID: restaurant.documentID,
                      restaurantName: restaurant.name,
                      rating: ratingView.rating!,
                      userInfo: user,
                      text: reviewTextField.text!,
                      date: Date(),
                      yumCount: 0)
  // Add this section here
  Firestore.firestore().collection("reviews").document(review.documentID)
      .setData(review.documentData) { error in
        if let error = error {
          print("Error writing new review: \(error)")
        } else {
          // Pop the review controller on success
          if self.navigationController?.topViewController == self {
            self.navigationController?.popViewController(animated: true)
          }
        }
  }
}

Run the app again. Sign in if you haven't yet, then head over to the Profile tab, click on My restaurants, and then click the + bar button to add a new restaurant to make sure everything works. Once you do, it should show up in the My restaurants section as well as the main restaurants tab immediately, because they're both powered by Cloud Firestore's real-time listeners.

Try adding some new reviews as well. Head over to the Restaurants tab, click on a restaurant to view details about it, then click the + bar button item to create a new review, too! They should show up in both the restaurant's detail view as well as the "My reviews" section of the Profile tab.

Adding type safety

This is all great, but one part of why we choose to write Swift over Objective-C is so that we can avoid implicit typing. If you look at our write code, we haven't really accomplished that goal, since our collections are specified by string labels and our documents are just dictionaries.

Security rules (discussed later on) can help ensure consistency on the server, but how can we use the language tools Swift gives us to ensure we don't try writing a document to the wrong collection on the client?

It turns out that, just like a conventional app, all we have to do is write a thin layer around Firestore itself. We already have the types that represent data on the client (Restaurant, User, Review, Yum)--all we have to do is write an API that consumes these types directly instead of dealing in dictionaries.

This is written for you at the top of Firestore+Populate.swift. Take a look, and optionally if you're inclined to do a little backtracking, try replacing the collection(_:) and setData(_:_:) calls in the code we just wrote with the more type-safe methods.

Update Existing Documents

Updating existing documents is equally simple. Open up EditRestaurantViewController.swift and add the following code to the saveChanges() method.

func saveChanges() {
  
  /* ... */

  if let downloadUrl = downloadUrl {
    data["photoURL"] = downloadUrl
  }

  Firestore.firestore()
      .collection("restaurants")
      .document(restaurant.documentID)
      .updateData(data) { err in
        if let err = err {
          print("Error writing document: \(err)")
        } else {
          self.presentDidSaveAlert()
        }
      }
}

updateData(_:_:) is a little different from setData(_:_:). Unlike setting data, updates only affect the specified fields and will fail if the document doesn't already exist. If you compare the data dictionary being set in this call with a restaurant document in Firestore, you'll notice the restaurant document has over twice as many fields. For the most part, user-updatable data will always follow this pattern, since there will most likely be fields that users are not allowed to edit.

Going back and looking at the code we just added, you may notice there's some inconsistencies being introduced into our database.

We'll talk about that first issue later in the workshop, but let's focus on that second one. Sure, it would be easy to write some logic on our client that would update the restaurant's average review score when a user adds a new review, but do we really trust our clients to do that? A client could -- maliciously or accidentally -- change the average score for a restaurant in a way we don't want.

Fortunately, Firebase provides a way for us to propagate these writes cleanly. Read on to find out how.

Sometimes it's best to have code running on a server. Either because you want it to perform actions that you wouldn't want to trust to a client, or because you want to offload processor-intensive work that would otherwise drain your user's battery.

Cloud Functions to the Rescue!

Now you can certainly set up and manage your own servers to complete these actions, but often that means worrying about credentials, server configuration, provisioning new servers, and decommissioning old ones.

Thankfully, Firebase offers a solution: Cloud Functions for Firebase. Cloud Functions let you automatically run backend code in response to events triggered by Firebase features and HTTPS requests. Your code is stored in Google's cloud and runs in a managed environment. This means there's no need to manage and scale your own servers.

Currently, Cloud Functions are available in Javascript and Typescript. We're going to look at examples using Typescript. If you aren't familiar with Typescript, it's a typed superset of Javascript that compiles down to plain Javascript. It's designed to make code easier to write and follow, and is especially useful for us because it lets us access the latest JavaScript features like async/await, simplifying promise management.

If you're not that familiar with Javascript, you may wonder what a "promise" is. The Promise object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. Once the operation is complete, we can access a result from the promise. That way we can wait for some work to be done--say, accessing some data from Cloud Firestore--and then complete an action based on the result, like writing that data to another location in Cloud Firestore.

Let's set up Cloud Functions and then see how they can help compute our restaurants' average reviews.

Setting up Cloud Functions

First, open up Terminal. We will be running some commands from the Firebase Command-Line Interface.

To install or upgrade the CLI run the following npm command:

npm -g install firebase-tools

To verify that the CLI has been installed correctly, open a console and run:

firebase --version

Make sure the Firebase version is above 3.18.0 so that it has all the latest features required for Cloud Functions. If not, run npm install -g firebase-tools to upgrade as shown above.

Authorize the Firebase CLI by running:

firebase login

Make sure you are in the codelab directory then set up the Firebase CLI to use your Firebase Project:

firebase use --add

Then select your Project ID and follow the instructions. You will be prompted to choose a project alias. You can call the project whatever you want, but you might want to stick with FriendlyEats or perhaps TrySwiftWorkshop. If you were creating a brand new project, you'd then call firebase init, but in this case we've already set up the project for you.

Cloud Functions work by responding to events that happen on the server. In your case, you will be writing a function that is triggered by write events in Cloud Firestore. Specifically, you want to trigger the function to run whenever a new review is written or updated so that a new average can be computed.

When using the Firebase SDK for Cloud Functions, your Functions code will live under the functions directory. Functions code is also a Node.js app and therefore needs a package.json that gives some information about your app and lists dependencies. To make it easier for you we've already created the functions/index.ts file where the code will go. To locate this file in the terminal, run

cd functions/src

ls

You can also open the file to inspect the contents, but as you can see, there isn't much there right now. Let's put it aside for a moment and take a look at package.json.

cd ..

cat package.json

The package.json file already lists two required dependencies: the Firebase SDK for Cloud Functions and the Firebase Admin SDK, which allows you to access firebase features server-side, such as performing actions on the database. To install them locally, run npm install from the functions folder:

npm install

Now that we've installed the required modules, let's dive into the code. Open index.ts if you haven't done so already.

index.ts

// TODO(DEVELOPER): Import the Cloud Functions for Firebase and the Firebase Admin modules here.

// TODO(DEVELOPER): Write the computeAverageReview Function here.

// TODO(DEVELOPER): Add updateAverage helper function here.

// TODO(DEVELOPER): Write the updateRest Function here.

// TODO(DEVELOPER): Add updateRestaurant helper function here.

First, you need to import the package for Cloud Functions. You'll also import the firebase-admin package, which is downloaded automatically with the firebase-functions dependency.

The Firebase Admin SDK allows you to access Firebase features server-side, not just in Cloud Functions, but also if you were to set up your own server. Say, for example, you had a function that wrote data to the database when a new user is authenticated for the first time. The Admin SDK would allow you to access the database from this Firebase Auth-triggered function. In our case, we're just going to be using the Firestore type so we can declare the parameter type in helper functions.

Ok, on to the function! We will make a Cloud Function that is triggered when a write happens in a review document. The format looks like this:

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
type Firestore = admin.firestore.Firestore;
const app = admin.initializeApp();
const settings = {timestampsInSnapshots: true};
admin.firestore().settings(settings);


export const computeAverageReview = functions.firestore
  .document('reviews/{reviewId}').onWrite((change, context) => {

  });

Notice the brackets around reviewId. This function uses wildcard notation, which enables us to capture all documents under reviews regardless of the ID. (It also assigns the document ID to a `reviewID` variable, which we can access later) The .onWrite trigger means this function will run whenever a write is made to the path, whether it's the first write, a deletion, or a change of any kind.

Ok, time to fill in the bulk of the code for this function.

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
type Firestore = admin.firestore.Firestore;
const app = admin.initializeApp();
const settings = {timestampsInSnapshots: true};
admin.firestore().settings(settings);

export const computeAverageReview = functions.firestore
  .document('reviews/{reviewId}').onWrite((change, context) => {
    // get the data from the write event
    const eventData = change.after.data();
    // get the previous value, if it exists
    const prev = change.before;
    const rating = eventData.rating;
    let previousValue
    if (prev.exists) {
        previousValue = prev.data();
        const prevRating = previousValue.rating;
        if (rating === prevRating) {
            console.log("not a new rating.");
            return null;
        }
    }
    // get the restaurant ID
    const restaurantID = eventData.restaurantID;
    // get a reference to the root of the firestore DB
    const db = app.firestore()
    // if a previous value exists, then it needs to be replaced
    // when computing an average. Otherwise, add the new rating
    if (prev.exists) {
        const difference = previousValue.rating - rating
        return updateAverage(db, restaurantID, difference, true);
    } else {
        return updateAverage(db, restaurantID, rating, false);
    }
  });

First, the function gets the data from the write event by calling change.after.data().

Then, it checks for a previous value using change.before. This is how the code distinguishes between a new review and an updated review.

If some other part of the review was changed besides the rating, there's no need to update the rating. In that case, the code returns and the rest of the function isn't run.

In order to update the average, you'll need to be able to access the location of the average in the database. Since the restaurant is located under restaurant/<restaurantID>, you'll need to get the restaurantID from the review.

Finally, if a previous value exists, updateAverage is called with prev set to true. Otherwise, it's called with prev set to false.

Notice we return the function updateAverage at the end of our function. In Cloud Functions, we resolve functions that perform asynchronous processing by returning a JavaScript promise. Returning a promise ensures that you avoid excessive charges from functions that run for too long or loop infinitely. Also, the promise ensures that your Cloud Function won't shut down before it's finished its work. In essence, without the promise, the Cloud Function doesn't know there's still work to be done.

Since this function calls the updateAverage function, it's a good time to implement it.

async function updateAverage(db: Firestore, restaurantID: string, newRating: number, prev: boolean) {

}

This function takes four parameters: db: a Firestore database, restaurantID: the string for the restaurantID, newRating: the new rating given in the review, and prev: a boolean indicating if that user had written a review for this restaurant before.

async function updateAverage(db: Firestore, restaurantID: string, newRating: number, prev: boolean) {
  const updateDB = db.collection('restaurants').doc(restaurantID);
  const restaurantDoc = await updateDB.get();
  if (!restaurantDoc.exists) {
      console.log("Document does not exist!");
      return null;
  }
  const oldRating = restaurantDoc.data().averageRating;
  const oldNumReviews = restaurantDoc.data().reviewCount;
  let newNumReviews = oldNumReviews+1;
  let newAvgRating = ((oldRating*oldNumReviews)+newRating)/newNumReviews;
  // no need to increase review numbers if not a new review
  // subtract the different made by the review
  if (prev) {
    newNumReviews = oldNumReviews;
    newAvgRating = ((oldRating*oldNumReviews)-newRating)/oldNumReviews;
  }
  await updateDB.update({averageRating: newAvgRating, reviewCount: newNumReviews});
  console.log("average updated");
  return null;
}

If you haven't encountered await before, it's essentially a way of asking a function to pause execution until that line of the function is complete. As much as we like trailing closures in Swift-land, sometimes this is more convenient. :)

Deploy the Function

The Function will only be active after you've deployed it. On the command line run firebase deploy:

firebase deploy

You'll see a console output similar to this:

i  deploying functions
i  functions: ensuring necessary APIs are enabled...
⚠  functions: missing necessary APIs. Enabling now...
i  env: ensuring necessary APIs are enabled...
⚠  env: missing necessary APIs. Enabling now...
i  functions: waiting for APIs to activate...
i  env: waiting for APIs to activate...
✔  env: all necessary APIs are enabled
✔  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged functions (X.XX KB) for uploading
✔  functions: functions folder uploaded successfully
i  starting release process (may take several minutes)...
i  functions: creating function computeAverageReview...
✔  functions[computeAverageReview]: Successful create operation. 
✔  functions: all functions deployed successfully!

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/friendlyeats-1234/overview

Keep in mind that the first time you are deploying functions for a project, it will take longer than usual because we're enabling APIs on your Google Cloud Project. The length of the deploy also depends on the number of functions being deployed and will increase as you add more over time.

Test the function

Time to see this function in action!

Pick a restaurant and check out its average review. Add a review, just like we did earlier. Once you're done, head back to the original RestaurantsViewController, and watch the average get updated! It may take a few moments to update the first time, as Cloud Functions have a cold start when they haven't been running for a while.

If the average still doesn't update after a few moments, you should double-check the command line output for errors during the deploy. If that looks successful, visit the Cloud Functions logs to see if any error messages appeared. You can also verify that the function was deployed successfully and see how many times it has been executed by exploring the different tabs in the Functions panel.

Now that you've seen what Cloud Functions can do, let's go back to the FriendlyEats app.

Next, let's add the ability to "yum" a review. This is our way of showing that a user is a fan of somebody else's review.

In order to properly yum a review, you're going to need to perform three steps:

  1. Make sure you have the latest version of this review from the database. This will ensure that when you set the new yumCount value, you'll be working with the most up-to-date data.
  2. Add a new document to the yums subcollection of the review, with the ID of the document equal to the user's ID. Right now, this will always succeed, but in a later step we will show you how to add some security rules that only allow this operation to happen if the document doesn't already exist. That will ensure that users can only "yum" a review once.
  3. Assuming that the operation in step 2 succeeds, you're then going to update the yumCount in the review to its new value.

Yumming a review, attempt #1

If you've gotten this far in the workshop, you might have a pretty good idea of how to do this already. Open up ReviewTableViewCell.swift and think about what you might want to add this code into the yumWasTapped(_:) method. All of the review information is already available in the local review variable.

Give it a try yourself, or keep reading to discover one way of doing it.

Get the latest yumCount

To get the latest yumCount, perform a simple getDocument call on the review. You can access the ID of the review document from the review variable, like so:

@IBAction func yumWasTapped(_ sender: Any) {
  let reviewReference = Firestore.firestore().collection("reviews").document(review.documentID)
  reviewReference.getDocument { (snapshot, error) in
    if let error = error {
      print("Got an error fetching the document: \(error)")
      return
    }
    guard let snapshot = snapshot else { return }
    guard let review = Review(document: snapshot) else { return }
    print("Right now, this review has \(review.yumCount) yums")
    let newYumCount = review.yumCount + 1
    // The rest of the code will go here!
  }
}

Attempt to write to the yums subcollection

Next, let's attempt to write a new document to the yums subcollection for this review. Note that the most important data we're adding is the ID of the document itself, which equals the ID of the signed-in user, as provided by Firebase Auth. The actual data that we're storing inside the document doesn't really matter in this case, but it might be nice to include the display name of the user, in case we ever want to show some kind of message in the future about who yummed this review.

Replace the line // The rest of the code goes here! from the previous step with the following code.

guard let currentUser = Auth.auth().currentUser else { return }
// First we are going to write a simple "Yum" object into our subcollection...
let newYum = Yum(documentID: currentUser.uid, username: currentUser.displayName ?? "Unknown user")
let newYumReference = reviewReference.collection("yums").document(newYum.documentID)
newYumReference.setData(newYum.documentData, completion: { (error) in
  if let error = error {
    print("Got an error adding the new yum document: \(error)")
  } else {
    print("Document set successfully")
    // TODO: Update the yumCount here
  }
})

Notice that when you create your Yum object, you're specifying that the ID of the document is equal to currentUser.uid.

Update the yumCount

Updating the yumCount is as easy as calling an update on the old review document, with the new value of yumCount. This is a simple update call on the review document, very similar to how you updated the restaurant document earlier in this workshop.

Replace the TODO line from the previous step with the following code:

reviewReference.updateData(["yumCount": newYumCount]) { (error) in
  if let error = error {
    print("Got an error updating the review count: \(error)")
  } else {
    print("yumCount incremented successfully")
  }
}         

Give it a try! You should now be able to successfully yum a review. Better yet, because you already have a real-time listener created for this reviews subcollection, the new yumCount should be displayed right away. So, everything works great... right?

Why everything we just did was wrong

Okay, now that we've spent the last several steps showing you one way to yum a review, can you think about why this approach might be incorrect? Think about it for a moment before continuing.

There are two main problems that exist with the code we added above.

1. There's a race condition. Suppose that two users decide to yum a review at the same time. If they both request the yumCount value at around the same time, and then both attempt to set the new yumCount value afterwards, one of these yums will be lost, because both clients are adding 1 to the old yumCount value.

2. We don't recover gracefully if the app were to crash in the middle of the operation. Suppose our app crashes (or our user loses internet access) after creating the yum document, but before incrementing the yumCount value. We'd be in a state where our could never actually yum the review. Creating a new yum document would fail (or it will once we add security rules), and because of that, we'll never get to increment the yumCount value.

So, what do we do?

Transactions are a way of performing a number of operations, essentially all at once, on the database.

In Cloud Firestore, transactions always consist of these five steps:

  1. Read in any data you need from the database. When you're working on the client, this means reading individual documents. You can't perform queries in a transaction.
  2. Perform any internal logic you might need to perform based on this data
  3. Get ready to write out any new data to the database
  4. Before committing, check to see if any of the documents you've touched in the process of this transaction (reading or writing) have changed. If they have, repeat steps 1-3. If you've retried these steps 5 times, the transaction fails.
  5. Commit your changes for real. If, for some reason, you can't perform any of these writes to the database, roll back all of the writes you were doing to make to the database.

This strategy is known as optimistic concurrency resolution, meaning that it's designed based on the assumption that you probably won't run into problems most of the time. The good news is you don't have to worry about locking rows or tables in your database, which can often lead to headaches in traditional databases. The drawback is that when you do run into concurrency issues, then things take longer to resolve because the entire transaction needs to be retried.

Now there are a couple of things to keep in mind with transactions.

First, because steps 1-3 could be repeated multiple times, you don't want to have any side-effects or external state dependencies in your transaction. Don't go displaying dialog boxes or anything like that in the middle of a transaction, and definitely don't try to pull data from other parts of your app that may change between transaction attempts.

Second, because a transaction will need to be retried anytime any of its documents changes, don't make a transaction overly-broad. Make sure you're accessing just enough data as needed to perform the transaction.

Yum a review with a transaction

With all of that in mind, let's go ahead and repeat the process of yumming a review, but this time using a transaction. Replace the entire contents of your yumWasTapped(_:) method with this code:

let reviewReference = Firestore.firestore().collection("reviews").document(review.documentID)
Firestore.firestore().runTransaction({ (transaction, errorPointer) -> Any? in

  // TODO: 1. Get the latest review from the database

  // TODO: 2. Perform any internal logic

  // TODO: 3. Write the new Yum object to our subcollection

  // TODO: 4. Update the yumCount

  return nil
})  { (_, error) in
  if let error = error {
    print("Got an error attempting the transaction: \(error)")
  } else {
    print("Transaction successful!")
  }
}

Transactions do look a little odd when you're first working with them. When you run a transaction, you will be passing all of your database operations to a Transaction object instead of working directly with the Firestore singleton. If there are any errors that occur while performing a transaction, you will create an NSError object and set the pointee of the transaction's NSErrorPointer to that object, so that it can surface in the completion handler.

In the completion handler, you can access any value you've decided to return from the transaction (in our case, we've decided to not return anything) as well as anything your errorPointer might be pointing to.

This might be a little clearer once you see the transaction in action, so let's get started!

Read a document from the database

To read in our document from the database, replace the first TODO line with this code.

// Make sure we have the most up-to-date number of yums
let reviewSnapshot: DocumentSnapshot
do {
  try reviewSnapshot = transaction.getDocument(reviewReference)
} catch let error as NSError {
  errorPointer?.pointee = error
  return nil
}

You can see that if we run into any issues, we make sure the errorPointer is pointing to a valid NSError object, and then we exit out of the transaction early.

Perform any internal logic

Let's do three things here. First, make sure we can convert our reviewSnapshot data to a proper Review object. Second, make sure we have a valid ID for our user. And third, increment our yumCount.

Replace the second TODO line with this code:

// We can convert our snapshot to a review object
guard let latestReview = Review(document: reviewSnapshot) else {
  let error = NSError(domain: "FriendlyEatsErrorDomain", code: 0, userInfo: [
    NSLocalizedDescriptionKey: "Review at \(reviewReference.path) didn't look like a valid review"
    ])
  errorPointer?.pointee = error
  return nil
}

guard let currentUser = Auth.auth().currentUser else {
  let error = NSError(domain: "FriendlyEatsErrorDomain", code: 0, userInfo: [
    NSLocalizedDescriptionKey: "You need to be signed in to Yum a review"
   ])
  errorPointer?.pointee = error
  return nil
}

// Finally, we can update the "Yum" count
let newYumCount = latestReview.yumCount + 1

Create the new yum document

Replace the third TODO line with this code to write the yum document to the database:

// Next we are going to write a simple "Yum" object into our subcollection...
let newYum = Yum(documentID: currentUser.uid, username: currentUser.displayName ?? "Unknown user")
let newYumReference = reviewReference.collection("yums").document(newYum.documentID)
transaction.setData(newYum.documentData, forDocument: newYumReference)

We don't need a try catch block here, because Cloud Firestore generally understands how to handle errors that happen at this point. If you get a permissions error, for instance, Cloud Firestore will exit the transaction immediately and set the error pointer to that object.

Update the yumCount

To update the yumCount for the review, replace the fourth TODO line with this code:

transaction.updateData(["yumCount": newYumCount], forDocument: reviewReference)

That's it!

Build and run

Give your app a try now, and you should now be able to yum a review, just like before! The difference now is that you're safe from race conditions, and if your transaction were to fail somewhere in the middle, the entire transaction would be rolled back so your database doesn't end up in an odd halfway-committed state. Hooray for transactions!

A more succinct version

This seems like a lot of code, and the truth is you could simplify this transaction somewhat if you didn't care about getting error messages specific to the part of the transaction that failed:

if let reviewSnapshot = try?  transaction.getDocument(reviewReference) {
  if let latestReview = Review(document: reviewSnapshot), let currentUser = Auth.auth().currentUser {
    // Finally, we can update the "Yum" count
    let newYumCount = latestReview.yumCount + 1

    // First we are going to write a simple "Yum" object into our subcollection...
    let newYum = Yum(documentID: currentUser.uid, username: currentUser.displayName ?? "Unknown user")
    let newYumReference = reviewReference.collection("yums").document(newYum.documentID)
    transaction.setData(newYum.documentData, forDocument: newYumReference)

    transaction.updateData(["yumCount": newYumCount], forDocument: reviewReference)

    return nil
  }
}
errorPointer?.pointee = NSError(domain: "FriendlyEatsErrorDomain", code: 0, userInfo: [
  NSLocalizedDescriptionKey: "There was an error in attempting to fetch your review"
  ])
return nil

But we prefer the more verbose version because it makes debugging easier.

A riddle

Congratulations! You've successfully added transactions to your app. While the Cloud Firestore team is hoping to add some simple operators to increment or decrement values on the database, transactions are currently the best way to perform any operation on the database that relies on having the most current data.

Which leads us to our next question: You've just recently added some logic that would probably work better as a transaction, because it, too, requires that you have the most current information. Can you guess what that would be? See if you can figure it out before moving to the next section.

Cloud Functions can also be subject to race conditions. If two users add or change their reviews around the same time, both of those writes to the database will trigger a function. These functions can run side by side. If one function is writing while the other is still reading and updating based on a previous read, you could end up with the wrong average review. This calls for a transaction! You're going to update the updateAverage function to use a transaction, just like you did for the yum count.

async function updateAverage(db: Firestore, restaurantID: string, newRating: number, prev: boolean) {
  const updateDB = db.collection('restaurants').doc(restaurantID);
  const transactionResult = await db.runTransaction(t => {
    return (async () => {
      const restaurantDoc = await t.get(updateDB);
      if (!restaurantDoc.exists) {
        console.log("Document does not exist!");
        return null;
      }
      const oldRating = restaurantDoc.data().averageRating;
      const oldNumReviews = restaurantDoc.data().reviewCount;
      let newNumReviews = oldNumReviews+1;
      let newAvgRating = ((oldRating*oldNumReviews)+newRating)/newNumReviews;
      // no need to increase review numbers if not a new review
      // subtract the different made by the review
      if (prev) {
        newNumReviews = oldNumReviews;
        newAvgRating = ((oldRating*oldNumReviews)-newRating)/oldNumReviews;
      }
      await t.update(updateDB, { averageRating: newAvgRating, reviewCount: newNumReviews });
      console.log("average updated");
      return null;
    })();
  })
  return transactionResult;
}

Deploy the Function

To update this function, run firebase deploy again:

firebase deploy

To verify that the functionality of updateAverage hasn't changed, go ahead and add another review, verifying that the average gets updated.

Up until now, you've been letting your clients read and write to the database without any restrictions. While this is helpful for development, you're definitely going to want to add some restrictions to your app to make sure that a client can't make

Be a hacker!

Time to become a hacker! Open up RestaurantsTableViewController.swift and uncomment these two lines in your viewDidLoad() method to enable access to the secret hacker page!

// Uncomment these two lines to enable SECRET HACKER PAGE!!!
let omgHAX = UIBarButtonItem(barButtonSystemItem: .bookmarks, target: self, action: #selector(goToHackPage))
navigationItem.rightBarButtonItems?.append(omgHAX)

Run your app, and at the top of the screen you should see a little book icon that will take you to the secret hacker page.

Let's focus on the first three hacks for now. Click on the "Edit another person's review" button. This will pick a review from the database (probably the first one in the Reviews collection) and change the text, the yumCount, the rating, and more.

It might be hard to find this specific review in the app, but you should be able to find it pretty easily by looking through the Review documents in the Firebase console.

Click the "Change a restaurant's details" button to change details about a restaurant.

Click the "Change another user's profile" button to change details about a user.

And then finally, click the "Add a fake review" button to create a fake review for a restaurant.

Well, these all seem like pretty bad operations to allow a client to perform, and if you've done any kind of production work in the past, you know that you can't just trust that your client will do the right thing. You can end up in situations where a client, either maliciously or accidentally, will attempt to perform actions it's not supposed to.

So let's figure out how to block these first three hacks through the use of security rules!

Adding security rules

Allow read access, prevent write access

Head on over to the Firebase console, open up the database panel, and click on the Rules tab. Right now, you probably have security rules that look like this:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read, write;
    }
  }
}

This allows global access to everything in your database. The /databases/{database}/documents block essentially points to the current database you're going to be working with, and it's essentially boilerplate code that you'll see at the beginning of any set of security rules.

The {database} wildcard here means, "Take any path element and assign it to a variable called database". You'll be using this pattern frequently in security rules.

The /{document=**} block essentially means, "All documents in my database" The =** operator inside a wildcard is essentially asking it to perform a recursive match.

If you change your security rules to:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }
  }
}

You have now allowed read access to all documents in your database, but blocked write access everywhere. This will prevent those bad hacks from happening, but it will also prevent all legitimate write operations as well! So let's look at how to allow the good write operations, while blocking the bad ones.

Users can only edit their own restaurants

First, let's add a rule that says users can only edit restaurants that belong to them. You're going to start by adding a match clause for all documents in the restaurants collection:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }
    
    match /restaurants/{restaurantID} {            
     // Restaurant rules go here
    }
  }
}

Similar to what we discussed earlier, the /restaurants/{restaurantID} line here basically means, "Match every document in the restaurants collection, and store the document ID in a variable called `restaurantID`" In this particular rule, we won't be taking advantage of the restaurantID variable, but you might in the future.

You're going to be working with two key important pieces of data here:

The request object contains information about the request. One very important property of this object is the request.auth.uid value, which contains the user's ID, as verified by Firebase auth on the server.

The resource object refers to the object in the database that is going to be read or updated. You can access the key-value pairs of this object by looking at the data property. For example, resource.data.ownerID will give you the value of the ownerID field inside a restaurant document.

By using these two pieces of data, you can add some security rules that states that a user can only make changes to a restaurant doc if they're listed as the owner. So if you want to change your own restaurant's name to "DON'T EAT HERE", I guess there's nothing to stop you. :)

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }
    
    match /restaurants/{restaurantID} {            
      allow update: if request.auth.uid == resource.data.ownerID;
    }
  }
}

Users can only edit their own reviews

Can you think of how you might write this security rules? Take a guess before you see the answer. It should look very similar to what you did for restaurants:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }

   // Add this section here to secure reviews
   match /reviews/{reviewID} {    
      allow update: if request.auth.uid == resource.data.userInfo.userID;
    }

    match /restaurants/{restaurantID} {            
      allow update: if request.auth.uid == resource.data.ownerID;
    }
  }
}

This will work, but we're also going to add one refinement here, and that's to say that a user can't change the owner of a review. If you think about it, if we didn't have this rule, a user could write some terrible review, then change the owner so it looked like another person was responsible for it. We don't want that to happen.

To add this rule, we're going to look at the request.resource object. This is nearly the same as the resource object, but it represents the document to be written to the database, instead of the one that's already there. This object is also "hydrated" by security rules, which means that even if you're not updating the userInfo.userID field in your update operation, you can still access it.

Change your security rules to look like this:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }

   match /reviews/{reviewID} {    
      // Update this line here
      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID);

    }

    match /restaurants/{restaurantID} {            
      allow update: if request.auth.uid == resource.data.ownerID;
    }
  }
}

Users can only edit their own user document

User documents are a little different in that the ID of the document itself is also the ID of the signed-in user. This makes our security rules simple, because we can say that a user can only update the document located at users/<their user ID>

Update your security rules to add in the section for users:

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }

   match /reviews/{reviewID} {    
      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID);

    }

    match /restaurants/{restaurantID} {            
      allow update: if request.auth.uid == resource.data.ownerID;
    }

    // Add this section here
    match /users/{userID} {            
      allow update: if request.auth.uid == userID;
    }
  }
}

Allow creation, too!

In the previous steps we added restrictions around updating documents, but we haven't done anything yet that allows you to create a new document. Let's do that next! Add the three `create` lines indicated below.

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
     allow read;
    }

   match /reviews/{reviewID} {    
      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID);
      // 1. Add this line
      allow create: if request.auth.uid == request.resource.data.userInfo.userID;

    }

    match /restaurants/{restaurantID} {            
      allow update: if request.auth.uid == resource.data.ownerID;
      // 2. Add this line
      allow create;
    }

    match /users/{userID} {            
      allow update: if request.auth.uid == userID;
      // 3. Add this line
      allow create: if request.auth.uid == userID;
    }
  }
}

These security rules will allow users to, respectively 1) Add a review, but only if they list themselves as the author, 2) Add any type of restaurant they want, 3) Only add a document at users/<their user ID>

You can also simplify that last block of rules by changing them to:

match /users/{userID} {            
  allow update, create: if request.auth.uid == userID;
}

Or even:

match /users/{userID} {            
  allow write: if request.auth.uid == userID;
}

Write is basically any operation that involves creating, updating, or deleting a document.

So with these sets of rules, you should be able to prevent those first four hacks, and most of the app should still function properly (yums won't be working yet, but we'll get to those later). But we're not done yet! Let's look at those last two hacks!

Be a hacker some more!

Go back to the hacker page and look at those last two hacks. You can click the Add invalid data button to add a restaurant with all sorts of badly-formed data to the database.

Next, click the Give Me 5 Stars! button. Assuming you've created a restaurant previously, this hack will find your restaurant and change its average rating to 5 stars.

Looks like we've got a little more securing to do with our database.

Add some more security rules

Don't let anybody change the review values

Let's tackle that last issue first. This one might seem difficult, because we've just added security rules that lets users edit their own restaurants. So how can we allow them to edit the name of their restaurant, but not their review score?

The answer is to compare the values in the resource.data object (which contains the values that currently exist in the database) and the request.resource.data object (which contains the "hydrated" values of the object to be written, and make sure these values aren't changed.

Find the restaurants block in your security rules and change them to the following

match /restaurants/{restaurantID} {            
  allow update: if request.auth.uid == resource.data.ownerID && 
                   request.resource.data.reviewCount == resource.data.reviewCount &&
                   request.resource.data.averageRating == resource.data.averageRating;
  allow create;                    
}

This will ensure that even if a user can edit the document, they can't change the reviewCount or averageRating values.

Won't this break our Cloud Function?

You might wonder if these rules are going to prevent our Cloud Function from being able to update the review score, and the answer is no. Cloud Functions, along with the server libraries, are generally run within a secure environment and aren't subject to the same security rules that a normal client would be.

So your Cloud Function can perform actions that an ordinary user couldn't. This is one other reason why Cloud Functions can be so helpful!

Accept only valid restaurant data

Cloud Firestore security rules contain a number of built-in functions that you can use to determine if your data is valid. The is operator, for instance, can determine if a value is of a specific type. So adding a line like

request.resource.data.city is string

would make sure that the value of your "city" field is a string.

You can start to see how we could use this to validate our restaurant data, but we'll want to perform this check when we add or update a restaurant. Does this mean we need to add in these rules twice? Both in the create and in the update check?

As it turns out, Cloud Firestore also supports being able to call custom functions. These functions can only consist of a single return statement, although you can pass in arguments to them or call other arguments. So first, let's create a function inside our /restaurant block that validates whether our restaurant data is valid. We're checking for types, but also making sure our string sizes seem reasonably and our values are within a certain range.

match /restaurants/{restaurantID} {  
// Add in this function
function isValidRestaurant(restData) {
    return restData.averageRating is number && 
            restData.averageRating >= 0 && restData.averageRating <= 5.0 &&
            restData.category is string &&
            restData.city is string &&
            restData.name is string &&
            restData.name.size() > 3 && restData.name.size() < 64 &&
            restData.photoURL is string &&
            restData.price is number && 
            restData.price >= 1 && restData.price <= 3 &&
            restData.reviewCount is number &&
            restData.reviewCount >= 0;
}
        
    allow update: if request.auth.uid == resource.data.ownerID && 
                     request.resource.data.reviewCount == resource.data.reviewCount &&
                     request.resource.data.averageRating == resource.data.averageRating;
  allow create;                    
}

We're creating this function inside the /restaurant block, but you could create it anywhere you want. It's up to you how you prefer to organize your security rules.

Now we can just call this function in our update and create checks, by passing in the resoure.request.data map.

match /restaurants/{restaurantID} {            
function isValidRestaurant(restData) {
  return restData.averageRating is number && 
          restData.averageRating >= 0 && restData.averageRating <= 5.0 &&
          restData.category is string &&
          restData.city is string &&
          restData.name is string &&
          restData.name.size() > 3 && restData.name.size() < 64 &&
          restData.photoURL is string &&
          restData.price is number && 
          restData.price >= 1 && restData.price <= 3 &&
          restData.reviewCount is number &&
          restData.reviewCount >= 0;
}

  allow update: if request.auth.uid == resource.data.ownerID && 
                   request.resource.data.reviewCount == resource.data.reviewCount &&
                   request.resource.data.averageRating == resource.data.averageRating &&
                   // Add this line here
                   isValidRestaurant(request.resource.data);
  // Change this line
  allow create: if isValidRestaurant(request.resource.data);                    
}

If you click Publish at this point, you should now have sufficient security rules set up that none of the hacks succeed anymore. Nice work protecting yourself!

Permit yums

There is one last feature we need to get working, and that's to permit a user to "yum" another person's review. In order to do this, we'll want to make sure that:

We can do all of these things by changing the /reviews block to this:

match /reviews/{reviewID} {    

  function onlyChangingYum() {
    return ((request.writeFields.size() == 1) && 
            ('yumCount' in request.writeFields) &&
            (math.abs(request.resource.data.yumCount - resource.data.yumCount) <= 1));
  }
    
  allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                    request.resource.data.userInfo.userID == resource.data.userInfo.userID) ||
                    onlyChangingYum();
  allow create: if request.auth.uid == request.resource.data.userInfo.userID;      
  match /yums/{yumID} {
    allow create: if request.auth.uid == yumID;
  }
}

A few notes about the rules you just added:

The request.writeFields property contains an array of all the fields that are to be written in this operation. This is how we can ensure that only the yumCount gets changed.

Note that we're nesting the /yums/{yumID} block inside the /reviews block. This sometimes makes it easier to write security rules for hierarchical data, although please note that none of the security rules cascade. That is, the rules you write for the /reviews documents only apply to that document and not the rules in the yums subcollection.

Click Publish one more time and, once the rules propagate, you should now be able to properly yum a review again. Better yet, now that we have these security rules, you won't be able to yum a review multiple times from within the app!


Your final set of security rules should now look like this:

service cloud.firestore {
  match /databases/{database}/documents {

    match /{document=**} {
      allow read;
    }

    match /reviews/{reviewID} {
      function onlyChangingYum() {
        return ((request.writeFields.size() == 1) && 
                ('yumCount' in request.writeFields) &&
                (math.abs(request.resource.data.yumCount - resource.data.yumCount) <= 1));
      }

      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID) ||
                       onlyChangingYum();
      allow create: if request.auth.uid == request.resource.data.userInfo.userID;      
      match /yums/{yumID} {
        allow create: if request.auth.uid == yumID;
      }
    }

    match /restaurants/{restaurantID} {
      function isValidRestaurant(restData) {
        return restData.averageRating is number && 
               restData.averageRating >= 0 && restData.averageRating <= 5.0 &&
               restData.category is string &&
               restData.city is string &&
               restData.name is string &&
               restData.name.size() > 3 && restData.name.size() < 64 &&
               restData.photoURL is string &&
               restData.price is number && 
               restData.price >= 1 && restData.price <= 3 &&
               restData.reviewCount is number &&
               restData.reviewCount >= 0;
      }

      allow update: if request.auth.uid == resource.data.ownerID && 
                    request.resource.data.reviewCount == resource.data.reviewCount &&
                    request.resource.data.averageRating == resource.data.averageRating &&
                    isValidRestaurant(request.resource.data);
      allow create: if isValidRestaurant(request.resource.data);
    }

    match /users/{userID} {
      allow update: if request.auth.uid == userID;
      allow create: if request.auth.uid == userID;
    }
  }
}

Note that this set of rules isn't entirely complete. We haven't really validated the content of the reviews or the user documents, and there are probably some other hacks a malicious client could get up to. But using these rules as an example, you can probably imagine how you would write rules to secure your app in these situations, too.

Populating an Infinite Table View

There's one issue in our main Restaurants page that we've been silently ignoring for the last few sections: it doesn't actually ever load more than 50 restaurants at a time. If you're developing a social media app or any kind of app that displays an infinite list of content, you'll probably want your app to automatically load more data when the user scrolls to the bottom. How might we do that with Firestore?

Before we delve into solutions, let's take a look at exactly the current state of things.

Almost immediately we can tell we won't be able to maintain all of these things, because if we have more than 50 elements in our table view and we want to update all of them in real time we'll need a query specifying more than 50 elements. So we have two options: either abandon realtime updates in the table view or allow for query performance degradation if the user keeps scrolling infinitely downward (at some point the user must stop scrolling downward, or their device will run out of memory).

For most apps it's better to choose the former and abandon realtime updates unless you really need them, because increasing the size of your query on each load means you also potentially increase the size of each update on each load, and you risk increasing your load time per new document as the user requests more data.

Let's take a look at what our solution looks like in code.

At the bottom of RestaurantsTableViewController, add an extension conforming to UIScrollViewDelegate and implement scrollViewDidScroll(_:). Eventually we'll use this callback to request more restaurants from our data source.

extension RestaurantsTableViewController: UIScrollViewDelegate {

  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    // Implement me!
  }

}

Now that we have all the callbacks in our main controller set up, let's talk about what the data source will look like. Unlike our previous data sources, which used Firestore's realtime capabilities to push data to the views, this new data source is going to be much lazier and only pull data when specifically requested to. Consequently we won't need any startUpdates() or stopUpdates() functions, because there's no listening for updates--each pull is a discrete event.

Make a new file called LazyRestaurantTableViewDataSource conforming to UITableViewDataSource and fill it out with some method stubs. Make sure to import Firebase at the beginning of the file, too.

import UIKit
import Firebase

@objc class LazyRestaurantTableViewDataSource: NSObject, UITableViewDataSource {

  // MARK: - UITableViewDataSource

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    fatalError("Unimplemented")
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    fatalError("Unimplemented")
  }

}

Looking back at our last data source implementations, we want to think about what Firestore data needs to go into our data source and what data our data source will then vend. Even though the behavior is different, the dependencies are the same (since they provide the same interface!) Our data source needs to:

Just like before, this means we need to write an initializer that takes a Query and a callback, and we'll need to add storage of some kind to actually hold our documents. In this case an Array will work fine.

Add the following to your new data source class:

private var restaurants: [Restaurant] = []
private var documents: [QueryDocumentSnapshot] = []
private let updateHandler: () -> ()
private let query: Query

public init(query: Query, updateHandler: @escaping () -> ()) {
  self.query = query
  self.updateHandler = updateHandler
}

This query is going to be unbounded, so the work of chopping the query into smaller queries and loading the data in chunks falls on our data source class. We've chosen an arbitrary chunk size of 50, but if you're feeling extra responsible, you can parameterize the chunk size as well.

We now have everything we need to start fetching data from Firestore (you may notice the sneaky documents array--we'll get to that later). Add a new method to the data source and implement it as follows:

public func fetchNext() {
  let nextQuery: Query
  if let lastDocument = documents.last {
    nextQuery = query.start(afterDocument: lastDocument).limit(to: 50)
  } else {
    nextQuery = query.limit(to: 50)
  }

  nextQuery.getDocuments { (querySnapshot, error) in
    guard let snapshot = querySnapshot else {
      print("Error fetching next documents: \(error!)")
      return
    }

    let newRestaurants = snapshot.documents.map { doc -> Restaurant in
      guard let restaurant = Restaurant(document: doc) else {
        fatalError("Error serializing restaurant with document snapshot: \(doc)")
      }
      return restaurant
    }

    self.restaurants += newRestaurants
    self.documents += snapshot.documents
    self.updateHandler()
  }
}

Note the start(afterDocument:) call early on in the method. This tells Firestore to give us results that start after the last document in our documents array.

Our data source is almost done. All that's left to add is a bit of state to prevent against multiple updates happening at the same time. Add this line to the data source class somewhere:

private var isFetchingUpdates = false

And in fetchNext():

public func fetchNext() {
  // 1. Add these four lines here
  if isFetchingUpdates {
    return
  }
  isFetchingUpdates = true
  let nextQuery: Query
  if let lastDocument = documents.last {
    nextQuery = query.start(afterDocument: lastDocument).limit(to: 50)
  } else {
    nextQuery = query.limit(to: 50)
  }

  nextQuery.getDocuments { (querySnapshot, error) in
    guard let snapshot = querySnapshot else {
      print("Error fetching next documents: \(error!)")
      // 2. Add this line next
      self.isFetchingUpdates = false
      return
    }

    let newRestaurants = snapshot.documents.map { doc -> Restaurant in
      guard let restaurant = Restaurant(document: doc) else {
        fatalError("Error serializing restaurant with document snapshot: \(doc)")
      }
      return restaurant
    }

    self.restaurants += newRestaurants
    self.documents += snapshot.documents
    self.updateHandler()
    // 3. Add this line at the end
    self.isFetchingUpdates = false
  }
}

That's all the Firestore-related logic out of the way--let's fill in the actual data source methods. Replace the bottom of the file with the following:

/// Returns the restaurant after the given index.
public subscript(index: Int) -> Restaurant {
  return restaurants[index]
}

/// The number of items in the data source.
public var count: Int {
  return restaurants.count
}

// MARK: - UITableViewDataSource

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  return count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(withIdentifier: "RestaurantTableViewCell",
                                           for: indexPath) as! RestaurantTableViewCell
  let restaurant = restaurants[indexPath.row]
  cell.populate(restaurant: restaurant)
  return cell
}

We're finally ready to replace our old data source with this shiny new one. Head back over to RestaurantsTableViewController.swift and change the type of the dataSource property to the new data source we just created.

lazy private var dataSource: LazyRestaurantTableViewDataSource = {
  return dataSourceForQuery(baseQuery)
}()

We'll need to change the dataSourceForQuery(_:) function as well, or the compiler will complain.

private func dataSourceForQuery(_ query: Query) -> LazyRestaurantTableViewDataSource {
  return LazyRestaurantTableViewDataSource(query: query) { [unowned self] in
    if self.dataSource.count > 0 {
      self.tableView.backgroundView = nil
    } else {
      self.tableView.backgroundView = self.backgroundView
    }

    self.tableView.reloadData()
  }
}

And, finally, let's get rid of those startUpdates() and stopUpdates() calls -- this means you can remove the deinit and viewWillDisappear() methods entirely. Without an active listener pushing updates, our view controller is now in charge of pulling updates from the server. Fortunately we've already done all the work to make that happen--all we have to do is call our data source's fetchNext() method in scrollViewDidScroll(_:).

func scrollViewDidScroll(_ scrollView: UIScrollView) {
  // Load more restaurants if the user is 100 points away from scrolling to the bottom.
  let height = scrollView.frame.size.height + 100
  let contentYoffset = scrollView.contentOffset.y
  let distanceFromBottom = scrollView.contentSize.height - contentYoffset
  if distanceFromBottom < height {
    dataSource.fetchNext()
  }
}

And to populate the database whenever the query changes we'll need to call populateNext() in query's didSet as well.

fileprivate var query: Query? {
  didSet {
    tableView.dataSource = nil
    if let query = query {
      dataSource = dataSourceForQuery(query)
      tableView.dataSource = dataSource
      dataSource.fetchNext() // Add this line
    }
  }
}

If you want to try this out in your app, you'll need to temporarily enable security rules to allow the "populate" function:

service cloud.firestore {
  match /databases/{database}/documents {

  match /{document=**} {
    allow read;
    // Add this line...
    allow write;
  }
  // Everything below can stay
  match /reviews/{reviewID} {

Re-run you app, hit populate a few more times, and then try scrolling to the bottom.

You'll notice also that changing queries and tapping into restaurants still works, since none of that logic depended on anything within the data source. We were able to change the behavior of our controller almost entirely through data source changes, and because we limited the side effects in our code, we didn't have to worry about our changes in the data source breaking things in other places.

Let's add in one more feature -- our customers would like the ability to sort reviews; either by yumCount, date added, or highest or lowest stars.

Luckily, we already have the UI ready to allow this -- we've just been hiding it the whole time! Comment out the following line in your RestaurantDetailViewController's viewDidLoad():

// Comment out this line to show the toolbar
// bottomToolbar.isHidden = true

You'll recall that earlier we set up the base query for reviews in Cloud Firestore to be as follows:

lazy private var baseQuery: Query = {
  Firestore.firestore().reviews
      .whereField("restaurantID", isEqualTo: restaurant.documentID)
}()

And we updated our query's didSet() method to re-run the query whenever it changes. So all of the plumbing is already set up. All we need to do is modify the query.

Fill out the sortReviewWasTapped() method so that it looks like this:

@IBAction func sortReviewsWasTapped(_ sender: Any) {
  let pickSortMethod = UIAlertController(title: "Sort reviews by...", message: nil, preferredStyle: .actionSheet)
  let sortByDefault = UIAlertAction(title: "Default", style: .default) { _ in
    self.query = self.baseQuery
  }
  let sortByDate = UIAlertAction(title: "Newest first", style: .default) { _ in
    self.query = self.baseQuery.order(by: "date", descending: true)
  }
  let sortByYums = UIAlertAction(title: "Most yums", style: .default) { _ in
    self.query = self.baseQuery.order(by: "yumCount", descending: true)
  }
  let sortByBest = UIAlertAction(title: "Best first", style: .default) { _ in
    self.query = self.baseQuery.order(by: "rating", descending: true)
  }
  let sortByWorst = UIAlertAction(title: "Worst first", style: .default) { _ in
    self.query = self.baseQuery.order(by: "rating", descending: false)
  }
  pickSortMethod.addAction(sortByDefault)
  pickSortMethod.addAction(sortByDate)
  pickSortMethod.addAction(sortByYums)
  pickSortMethod.addAction(sortByBest)
  pickSortMethod.addAction(sortByWorst)
  present(pickSortMethod, animated: true)
}

Build and run the app -- you should now be able to sort reviews any way you'd like by clicking the "Sort reviews" button. And while this probably works, there are two issues you should be aware of:

First, notice that the first time you ask to form a particular sort, you're receiving an error in your Xcode console to create a custom index. This is because you are now performing an equality search one one field, followed by a sort on another field.

Second, notice that in order to sort by date, we had to explicitly add a "date" timestamp to all of our reviews. This is because in Cloud Firestore, the document ID is essentially random. This might be a surprise to you if you're used to working with the Realtime Database, where document IDs always increase over time. So if you expect to ever query your documents by date, you'll need to add them explicitly to your documents, either on the client or through a cloud function.

Structuring The Database, Re-revisited

This example highlights a couple of disadvantages of putting our restaurant reviews in a top-level collection like this. If all of our reviews for a particular restaurant were already grouped into subcollections, retrieving them sorted by any particular field would be a trivial operation. But because we're already performing a search of our top-level collection by restaurantID, sorting by a second field requires a composite index.

One other issue we might run into is that Cloud Firestore has a limit of 500 writes per second to a collection that has fields that are sequential (Cloud Firestore needs time to build those indexes), which is the case if we're adding a date timestamp to all of our reviews. If our app was tremendously popular and had lots of reviews all written at once to the same reviews collection, we might run up against this limit. That would probably be less of an issue if each review was in its own subcollection.

None of this means our original choice was necessarily wrong. There will always be trade-offs when it comes to database design, and it's just good to know what those trade-offs are.

As you might recall, there are a couple of places in the Cloud Firestore database where you are dealing with denormalized data. Every restaurant review, for instance, includes the name of the restaurant, which helps to populate the My Reviews section of the profile page. Additionally, every restaurant review includes the name and profile picture of the author, which is used to help populate the review in the RestaurantDetailsViewController.

This kind of data denormalization is really useful in a NoSQL database like Cloud Firestore because it allows us to populate the entire contents of the table view in both the Restaurant view controller and the Profile view controller with a single query. The drawback is that if a user does decide to change the name of their restaurant or their display name, that change has to be made to every document in the Reviews collection.

Luckily, this change isn't too difficult to make. In fact, let's take a look at two approaches we can take to make this change.

Set up an example

If you want to follow along, the best way to do this is to write a review for your own restaurant. First, create a restaurant from the "My Restaurants" page, if you don't have one yet.

Then write a review for your own restaurant...

Then go back to the My Restaurants page, and edit the name of your restaurant.

The name of the restaurant will change, but if you go back to your My Reviews section, you'll see that the old name of the restaurant is still there. Awww...

So, how do we fix this?

Option #1: Change data on the client

One option is to simply make sure that anytime we update the restaurant name in our EditRestaurantViewController, we also update every review that mentions this restaurant. If you think about it. this is fairly easy to do with a query, and we could loop through the results of the query and create a series of document writes to change the name of the restaurant in every review.

We're going to make this process slightly more efficient, though, by using a batch write. You can think of a batch write as something like a transaction, but it's only designed to write to a series of documents at once -- there's no reading of the database required.

These writes are also atomic (meaning that, for all intents and purposes, all writes are committed at once), and if part of the batch fails, the entire series of writes will be rolled back, to keep your database in a consistent state. Batch writes also incur less overhead than several different write requests.

To perform a batch write on the database, you create a WriteBatch object, make as many updateData, setData or deleteDocument calls you want on it (up to 500), and then call commit() to have the WriteBatch object attempt to make these changes to the database.

So let's create the code to do this. Open up EditRestaurantViewController.swift and replace the last call in your saveChanges() method (the updateData call with the trailing closure) to the following:

let restaurantToEdit = Firestore.firestore().collection("restaurants").document(restaurant.documentID)
// Create the batchWrite object
let batchWrite = Firestore.firestore().batch()
batchWrite.updateData(data, forDocument: restaurantToEdit)

// And now, let's fix our denormalized data.
Firestore.firestore().collection("reviews").whereField("restaurantID", isEqualTo: restaurant.documentID).getDocuments { (snapshot, error) in
  if let error = error {
    print("Received an error attempting to get reviews! \(error)")
    return
  }
  if let snapshot = snapshot {
    for reviewDoc in snapshot.documents {
      guard let name = data["name"] else { continue }
      batchWrite.updateData(["restaurantName": name], forDocument: reviewDoc.reference)
      print("Updating a review, too!")
    }
  }

  batchWrite.commit(completion: { (error) in
    if let error = error {
      print("Error writing document: \(error)")
    } else {
      self.presentDidSaveAlert()
    }
  })
}

You'll notice that we're adding the updateData call for the restaurant itself to the WriteBatch. You might also notice that if you try to run this right now, it doesn't work! This is because our security rules don't allow us to make this change.

Luckily, we can change our security rules to allow this operation. Go back to the Security Rules section of the Firebase Console and change the /reviews block to look like this:

match /reviews/{reviewID} {    
  function onlyChangingYum() {
    return ((request.writeFields.size() == 1) && 
            ('yumCount' in request.writeFields) &&
            (math.abs(request.resource.data.yumCount - resource.data.yumCount) <= 1));
  }

  // This function is new
  function onlyChangingRestaurantName() {
    return ((request.writeFields.size() == 1) && 
            ('restaurantName' in request.writeFields))                    
  }

  // This function is new
  function userIsOwnerOfRestaurant() {
    return get(/databases/$(database)/documents/restaurants/$(resource.data.restaurantID)).data.ownerID == request.auth.uid        
  }

  allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                   request.resource.data.userInfo.userID == resource.data.userInfo.userID) ||
                   onlyChangingYum() || 
                   // This line is new, too.
                   (onlyChangingRestaurantName() && userIsOwnerOfRestaurant());
  allow create: if request.auth.uid == request.resource.data.userInfo.userID;      
  match /yums/{yumID} {
    allow create: if request.auth.uid == yumID;
  }
}

One new operation we're using is the get() function in the userIsOwnerOfRestaurant() function. This function can read in any arbitrary document in the database and let you access its fields through the data property.

So in our case, we're looking up the owner of the restaurant that's referenced in the review object by pulling in the appropriate document in the restaurant collection. Then we're making sure the ownerID of that restaurant is equal to the current user.

Publish your security rules, and give it another try! This time, when you change the name of your restaurant, that change should be made to all of the review objects as well.

So this is one way to deal with denormalized data, but now that we've done this, revert your changes back to the old method, because we want to explore another option:

Firestore.firestore().collection("restaurants").document(restaurant.documentID).updateData(data) { err in
  if let err = err {
    print("Error writing document: \(err)")
  } else {
    self.presentDidSaveAlert()
  }
}

Option #2: Change data server-side with Cloud Functions

We've seen how the restaurant name can be updated client side, but adding all those special case security rules to allow this change seems awkward. And if our app had a lot of denormalized data to take care of, those security rules would get even more complex than they already are. :)

So another, and probably better, option is to use Cloud Functions for Firebase to make these changes to our reviews documents when we notice that the name of a restaurant has changed. This is a great use case for Cloud Functions, and coincidentally, there are a couple of TODOs left in our index.ts to add this in. So let's get started!

Add this line to the index.ts file in your functions/src directory.

export const updateRest = functions.firestore.document('restaurants/{restaurantID}').onUpdate((change, context) => {

});

While the updateAverage function used an .onWrite trigger, notice that updateRest uses an .onUpdate trigger. This is because we want to trigger the function only when a change is made to the restaurant, not when it's first created. When a restaurant is first created, there won't be any reviews to update.

Start filling out the rest of the function, like so:

export const updateRest = functions.firestore.document('restaurants/{restaurantID}').onUpdate((change, context) => {
    const eventData = change.after.data();
    const restaurantID = context.params.restaurantID;
    const prevEventData = change.before.data();
    const name = eventData.name;
    const oldName = prevEventData.name;
    if (oldName === name) {
        console.log("change was not in name. No need to update reviews.");
        return null;
    }
    const db = app.firestore();
    // if name was updated
    return updateRestaurant(db, restaurantID, name);
});

First, the function gets the restaurantID where the change occurred. Wildcard matches are extracted from the document path (the 'restaurants/{restaurantID}' part of the function) and stored in context.params. Since restaurantID is a wildcard, we can call context.params.restaurantID to extract it.

Next, we get the data from the current event, as well as the previous data from before the change occurred. We get the current and previous restaurant name, and then compare them. If they're the same, we return out of the function. There's no need to update the name in the reviews if it hasn't changed.

Then, the function gets a reference to Firestore.

Finally, we update the restaurant name by calling updateRestaurant. Let's create that helper function now.

In the updateRestaurant function, we'll once again use a batch write. This makes sure all review documents are changed at once. It also will ensure that if the write fails for some reason, the entire batch write is rolled back, so your data remains in a consistent state.

Add this function to your index.ts file:

// update changes to restaurant
async function updateRestaurant(db: Firestore, restaurantID: string, name: string) {
    const updateRef = db.collection('reviews');
    // query a list of reviews of the restaurant
    const queryRef = updateRef.where('restaurantID', '==', restaurantID);
    const batch = db.batch();
    const reviewsSnapshot = await queryRef.get();
    for (const doc of reviewsSnapshot.docs) {
        await batch.update(doc.ref, {restaurantName: name});
    };
    await batch.commit();
    console.log(`name of restaurant updated to ${name}`);
}

This function queries all reviews where the restaurantID matches our restaurant. It iterates through the results of the query, and updates the name in each document. Finally, the batch of writes is committed.

Deploy this function by calling

firebase deploy

from the command line. When it's finished, go back into your app and change the name of a restaurant.

After a few moments, you should see a message appear in the Logs tab of the Functions section of the Firebase console about how your restaurant name has been changed...

...and it will now appear that way in your My Profile screen as well.

And this also means you can revert your security rules to the (somewhat) simpler version that you had before:

match /reviews/{reviewID} {
      function onlyChangingYum() {
        return ((request.writeFields.size() == 1) && 
                ('yumCount' in request.writeFields) &&
                (math.abs(request.resource.data.yumCount - resource.data.yumCount) <= 1));
      }

      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID) ||
                       onlyChangingYum();
      allow create: if request.auth.uid == request.resource.data.userInfo.userID;      
      match /yums/{yumID} {
        allow create: if request.auth.uid == yumID;
      }
    }

Not everybody in the world will have the same great internet connection that you have right now. Users will want to use your app when going through tunnels, on airplanes, or in foreign countries where they turn of data roaming. So it's important to make sure your app still functions reasonably, even when your users internet connection

Add offline support

To get your app to work offline, you'll need to perform the following steps:

Oh, that's right. There are no steps! Cloud Firestore is set up by default to work offline. Let's give it a try to see what that looks like.

Try out offline support

You can try out offline support by putting your phone into airplane mode (if you're testing on a device), turning off your wifi (if you're testing on a simulator), or using Apple's Network Link Conditioner, which can be found in the Additional Tools for Xcode.

Once you're offline, you'll notice that not much has changed. If you go to your profile page, you'll still see your profile, along with all of your restaurant reviews. And if you go to the restaurants page, you'll still see a bunch of restaurants.

Basically, what's happening here is that Cloud Firestore has been caching all of your data as it receives it from the network. When it notices that it can no longer connect to the database, it serves up the cached version instead of trying to fetch .

What's even more interesting is that you can still run queries on all of this cached data -- the biggest difference is that, obviously, these queries will only run on top of whatever data you have cached, and not all of the data in your database.

Give it a try, though! While you're offline, set up a filter or sort by a particular value and notice that everything still works.

In fact, probably the only difference you'll notice if you're offline is that if you click on a restaurant you haven't visited yet in this codelab, none of the reviews will be present.

What about writes?

So reading and querying data seems to work well when you're offline. What about writes? Those work offline, too!

When you perform any kind of write to the database, that write operation gets stored locally. And Firebase will "replay" those writes on top of your current data set, so that it looks to your user like that write has been applied immediately, even if it's having trouble contacting the server.

Let's try it by adding a review to a restaurant. Click on any restaurant; give it a review.

Then click the save button and... huh. Looks like our app is frozen. What's going on here?

The issue is that while your write operation has been applied immediately to the database, the callback doesn't get called until your app has received confirmation from the server.

So, how do we fix this? In most situations, the best solution is to simply move the code to dismiss the view controller out of the completion handlers. Remember, as far as Cloud Firestore is concerned, it will look like that change has been applied immediately, so you generally don't need to worry about waiting for the server to respond.

Open up the NewReviewViewController and find the doneButtonPressed() function. Change this code:

Firestore.firestore().reviews.document(review.documentID)
    .setData(review.documentData) { (error) in
      if let error = error {
        print(error)
      } else {
        // Pop the review controller on success
        if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
          self.navigationController?.popViewController(animated: true)
        }
      }
}

To this:

Firestore.firestore().reviews.document(review.documentID)
    .setData(review.documentData) { (error) in
      if let error = error {
        print(error)
      } else {
        print("Write confirmed by the server!")
      }
}
if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
   self.navigationController?.popViewController(animated: true)
}

You can do the same thing in both the AddRestaurantView controller at the end of the Save Changes function...

Firestore.firestore().restaurants.document(restaurant.documentID)
    .setData(restaurant.documentData) { err in
      if let err = err {
        print("Error writing document: \(err)")
      } else {
        print("New restaurant confirmed by server")
      }
}
// Move this line until after the callback
self.presentDidSaveAlert()

As well as your EditRestaurantViewController

Firestore.firestore().collection("restaurants").document(restaurant.documentID).updateData(data) { err in
  if let err = err {
    print("Error writing document: \(err)")
  } else {
    print("Edit confirmed by the server.")
  }
}
self.presentDidSaveAlert()

Conflict resolution

If you want to see how conflict resolution works, try running the app in two simulators at once (or one simulator and one device). Start up your apps, go to the "My restaurants" page, then go offline. Next, try editing the same restaurant in both of your devices at once, and then make them both go back online.

What you'll see is that the device that comes back online second overwrites the edits from the device that came back online first. This "last write received always wins" approach is usually sufficient enough for most conflict resolution situations, but you can always add on security rules to prevent this (by adding, for instance, timestamps or version numbers with your edits) or write Cloud Functions to do more sophisticated handling of incoming data.

Yums in an offline world?

While you're still offline, try "yum"ing a review. You might notice that this call fails, and that's intentional. When you attempt to yum a review, you're doing so in a transaction, and you generally write transactions when you want to make absolutely sure you're working with the most recent data on the server. So a yum in this case will fail.

How can you address this? There are several options, but let's consider two:

  1. Do nothing. It's probably fine if a user isn't able to yum an app while offline. You can analyze the error that's returned to get a pretty good sense that it's failing because you're not connected to the network (instead of being stopped by a security rule), and then either display a message to the user or fail silently, depending on your situation.
  2. Change the way we "yum" a review! Instead of attempting to edit the review directly, you could have your app write to a new "Pending yums" collection. Then you could create a Cloud Function that looks at any new documents in this collection and updates the actual review document. This is a nice approach because it removes a few security issues around yums that still exist, simplifies your existing security rules, and allows you to persist these pending writes locally and send them down once your client has reconnected.

    The biggest problem with this approach is that the yum count won't have a chance to update until your user has reconnected, so this will look like it's failed silently until your user comes back online. You would have to find clever (or hacky) ways of "faking" the UI, but being prepared to overwrite it when you get a real value back from the server.

Both of these approaches are reasonable, and if you're interested in seeing how we implement that second option, move on to the next section!

Our Yums have gotten pretty complicated; they require a fairly complex transaction on the client, and a large set of security rules to make sure they work as intended. You may find this happens often with your own apps -- some client action that seems easy at first gradually becomes more complicated and requires more complex security rules to implement correctly.

In many of these cases, it might make sense to simplify your client logic, and have a Cloud Function do more of the heavy lifting.

We're going to do that here with our yums. Instead of incrementing yums directly in the restaurant reviews, we're going to have every client write to a "pendingYums" collection on the server. A cloud function will then be responsible for noticing these pendingYums, running some logic that will increment the appropriate review's yumCount, and then add a new yum document, assuming one hasn't been added already.

To do this, open up ReviewTableViewCell and change your yumWasTapped method to something much simpler:

@IBAction func yumWasTapped(_ sender: Any) {
  guard let currentUser = Auth.auth().currentUser else {
    print("You need to be signed in to Yum a review!")
    return
  }
  let pendingYum = ["review": review.documentID,
                    "userID": currentUser.uid,
                    "userName": currentUser.displayName ?? "Anonymous"]
  Firestore.firestore().collection("pendingYums").addDocument(data: pendingYum)

  // We can "fake" the data if we're offline.
  review.yumCount += 1
  showYumText()
}

This function will simply create a new document in a pendingYums collection. We add a little bit of trickery at the end to make it look like our yumCount has incremented right away.

Next open up the index.ts file that contains your Cloud Functions and add these two functions at the bottom:

export const watchForYums = functions.firestore.document('pendingYums/{pendingYumID}').onCreate((snapshot, context) => {
   // get the data from the write event
   const eventData = snapshot.data();
   const reviewID = eventData.review;
   const userID = eventData.userID;
   const userName = eventData.userName;
   const db = app.firestore();
   const pendingYumID = context.params.pendingYumID;
   console.log(`Found a pending yum for review ${reviewID} by ${userName} (Id: ${userID}`);
   return addYum(db, reviewID, userID, userName, pendingYumID);
});

async function addYum(db: Firestore, reviewID: string, userID: string, userName: string, docID: string) {
   // First, we need to figure out if there's already a yum by this user
   const transactionResult = await db.runTransaction(t => {
       const pendingYum = db.collection('pendingYums').doc(docID)
       const targetReview = db.collection('reviews').doc(reviewID);
       const thisUsersYum =  targetReview.collection('yums').doc(userID);
       return (async () => {
           const existingYumDoc = await t.get(thisUsersYum);
           const targetReviewDoc = await t.get(targetReview);
           let shouldUpdateReview = true;
           if (existingYumDoc.exists) {
               console.log("User has already yummed this review. We won't update it.");
               shouldUpdateReview = false;
           }
           if (!targetReviewDoc.exists) {
               // This should never happen (famous last words)
               console.error("Target review doc doesn't exist?!");
               shouldUpdateReview = false;
           }
           if (shouldUpdateReview) {
               // We're going to increment the yumCount
               const oldYumCount = targetReviewDoc.data().yumCount;
               const newYumcount = oldYumCount + 1
               await t.update(targetReview, {yumCount: newYumcount})
               console.log("Review yumCount has been updated to " + newYumcount)

               // And add the new yum document to indicate that this user has already yummed the review
               const newYumData = {username: userName};
               await t.set(thisUsersYum, newYumData);  
               console.log("New yum document has been added");            
           }
           // No matter what happens, we'll want to delete the pending yum
           await t.delete(pendingYum);
           console.log("Pending yum removed");
           return null;
       })();
   });
   return transactionResult;
}

While this code may be long, most of this should look fairly familiar to you by now. The watchForYums listener activates anytime a new document is written to the pendingYums collection.

The addYum asynchronous function creates a transaction that: a) Looks to see if this user has already "yummed" this review, b) Grabs the old review yumCount, c) Writes the new yumCount value, d) Adds a new document to the review's subcollection to indicate that our user has now yummed this review, and e) Deletes the old pending yum.

Run firebase deploy --only functions to deploy your functions.

Now that you've done this, you can simplify your security rules. Change the /reviews/ block of your security rules to the following:

    match /reviews/{reviewID} {
      allow update: if (request.auth.uid == resource.data.userInfo.userID &&
                       request.resource.data.userInfo.userID == resource.data.userInfo.userID);
      allow create: if request.auth.uid == request.resource.data.userInfo.userID;      
    }

Notice that we've removed all write access to the yums subcollection, because that's all handled by our Cloud Functions, which aren't constrained by these rules.

Finally add a new section to make sure you can only create a pending yum with your userID.

    match /pendingYums/{yumID} {
      allow create: if request.auth.uid == request.resource.data.userID;
    }

Publish your new security rules. Then, try yumming a review! You'll see some output in the Cloud Functions console to indicate that your yumCount has been incremented. If you try yumming a review a second time, you'll see in your console that it's been rejected.

If you watch the database portion of the console, you might see documents being written and then quickly deleted in your pendingYums collection.

Over on the client, you might notice that we're faking the yumCount when you first click on it -- this is primarily because we don't have a real-time listener set up on the restaurant details page -- but if you go back to the restaurant overview page, then go into the restaurant details page again, the count should now be accurate.

And because our pending yums are now just normal ol' writes again, you can yum a review while offline, and the review will be properly updated when your client goes online again.

This pattern of "simple writes, more complex follow-up actions performed by Cloud Functions" is a pattern you should get used to seeing. We've used it a couple of times now in our app, and it's fairly common to see out in the real world, once apps have reached a certain level of sophistication.

Congratulations! You've taken a basic sample app and added a number of features including security rules, querying and filtering of data, server-side logic, real-time updates on the client, atomic writes and transactions, and offline support to turn it into something that almost resembles a real production app.

If you want to download the final version of the sample app, you can do so here.

There's still more to learn about Cloud Firestore, and we encourage you to visit our documentation if you want to find out more. Firestore's iOS client library is also open source, so if you'd like to contribute or have an issue to file, you can do so here.

Happy hacking!