How to offload complex Firestore queries into Firebase Cloud Functions and parse results on iOS/Swift client?

Viewed 88

So currently, my client (iOS/Swift) is fetching documents by filtering on a whereField(...) and then further filtering down the results based on a few other fields. This means that some objects are fetched, then discarded, wasting precious resourced and bandwidth!

Can I do this complex filtering on a firestore cloud function than somehow parse the filtered results into the same firestore object on the client? To be clear, I know how to write cloud functions that fetch and return documents, I am more interested to understand the transport method to the client.

Code snippet to provide some context:

static func unmatchedChatsHelper(cursor: Any?, promise: @escaping (Result<UnmatchedChatsCursorResponse, Never>) -> Void) {
    let earliestEventTime = Date()
    // (1) Filtering done on backend.
    var query = Firestore.firestore().collection("chats")
        .whereField("eventTime", isGreaterThan: earliestEventTime)
        .order(by: "eventTime")
        .order(by: "createdAt", descending: true)
    if let cursor = cursor as? DocumentSnapshot {
        query = query.start(afterDocument: cursor)
    }
    query.getDocuments { (querySnapshot, error) in
        guard let querySnapshot = querySnapshot else {
            fatalError("Query snapshot failed: \(error?.localizedDescription ?? "no error")")
        }
        let transformedModels = querySnapshot.documents.compactMap { (chatQueryDocumentSnapshot) -> ChatModelFirebase? in
            do {
                return try chatQueryDocumentSnapshot.data(as: ChatModelFirebase.self)!
            } catch let error {
                logEvent(AnalyticsEventDidDropDocument, [
                    "document_id": chatQueryDocumentSnapshot.documentID
                ], error)
                return nil
            }
        }
        let me = Auth.auth().currentUser!.uid
        // (2) Filtering done on client! This means some chats are retrieved unnecessarily. 
        let unmatchedChats = transformedModels.filter {
            !$0.participants.contains(me)
                && $0.numParticipants < 6
                && !$0.declinedParticipants.contains(me)
                && $0.isDebug == DebugGating.isDebugBuild()
        }
        let cursor = querySnapshot.documents.last
        promise(.success(UnmatchedChatsCursorResponse(models: unmatchedChats, cursor: cursor as Any)))
    }
}

I have three options:

  1. [My Choice/Best Option] Same as above, but use Firestore.Decoder to parse results ChatModelFirebase.
  2. [Parsing Logic is Dangerous] Do the query on a cloud function, return JSON, and manually parse the results into ChatModelFirebase.
  3. [Too Costly To Do] Update my firestore document structure for all queries to be done via the whereField(..) but this is complex since whereField is limited in its options.

Anything else I a missing? Is there a better way to do this?

1 Answers

In case you end up keeping your current logic (filtering on the client), here are some tips so you can limit your documents some more.

  1. You can't use declinedParticipants in a query, because queries can only ask "which documents HAVE this value", and not "which documents DON'T have this value". You can maintain and use a notDeclinedParticipants field instead:
.whereField("notDeclinedParticipants", arrayContains: me)
  1. Since numParticipants contains distinct small integers, you can use a 'in' query:
.whereField("numParticipants", in: [1, 2, 3, 4, 5])
  1. To add a range filter based on eventTime and run a compound query, create a composite index that will include all the other fields that you need to put in your queries, plus eventTime.

Example (untested) code:

Firestore.firestore().collection("chats")
    .whereField("notDeclinedParticipants", arrayContains: me)
    .whereField("numParticipants", in: [2, 3, 4, 5])
    .whereField("isDebug", isEqualTo: DebugGating.isDebugBuild())
    .whereField("eventTime", isGreaterThan: earliestEventTime)
Related