6

After some research, it's seems clear that I cannot use FireStore to query items a given array does NOT contain. Does anyone have a workaround for this use case?...

After a user signs up, the app fetches a bunch of cards that each have a corresponding "card" document in FireStore. After a user interacts with a card, the card document adds the user's uid to a field array (ex: usersWhoHaveSeenThisCard: [userUID]) and the "user" document adds the card's uid to a field array (ex: cardsThisUserHasSeen: [cardUID]). The "user" documents live in a "user" collection and the "card" documents live in a "card" collection.

Currently, I'd like to fetch all cards that a user has NOT interacted with. However, this is problematic, as I only know the cards that a user has interacted with, so a .whereField(usersWhoHaveSeenThisCard, arrayContains: currentUserUID) will not work, as I'd need an "arrayDoesNotContain" statement, which does not exist.

Finally, a user cannot own a card, so I cannot create a true / false boolian field in the card document (ex: userHasSeenThisCard: false) and search on that criteria.

The only solution I can think of, would be to create a new field array on the card document that includes every user who has NOT seen a card (ex: usersWhoHaveNotSeenThisCard: [userUID]), but that means that every user who signs up would have to write their uid to 1000+ card documents, which would eat up my data.

I might just be out of luck, but am hoping someone more knowledgeable with NOSQL / FireStore could provide some insight.

// If any code sample would help, please let me know and I'll update - I think this is largely conceptual as of now
bhersh90
  • 83
  • 1
  • 8

2 Answers2

2

As you've discovered from query limitations, there is no easy workaround for this using Cloud Firestore alone. You will need to somehow store a list of documents seen, load that into memory in the client app, then manually subtract those documents from the query results of all potential documents.

You might want to consider augmenting your app with another database that can do this sort of operation more cleanly (such as a SQL database that can perform joins and subqueries), and maintain them in parallel.

Either that, or require all the documents to be seen in a predictable order, such as by timestamp. Then all you have to store is the timestamp of the last document seen, and use that to filter the results.

Doug Stevenson
  • 297,357
  • 32
  • 422
  • 441
  • Thanks Doug, unfortunately I can't setup an additional db at this time, but the timestamp idea might just work. So basically order by oldest and add a .whereField("timestamp", isGreaterThan: lastTimestampSeen) and save the user's lastTimestampSeen property to the user document. It's not perfect but should suffice for now – bhersh90 Sep 05 '19 at 21:07
  • @bhersh90 I think we can do this without adding or maintaining an additional database or even changing the structure. I threw out an answer - may or may not work for your use case. – Jay Sep 07 '19 at 15:34
1

There is an accepted and good answer, however, it doesn't provide a direct solution to the question so here goes... (this may or may not be helpful but it does work)

I don't know exactly what your Firestore structure is so here's my assumption:

cards
   card_id_0
      usersWhoHaveSeenThisCard
         0: uid_0
         1: uid_1
         2: uid_2
   card_id_1
      usersWhoHaveSeenThisCard
         0: uid_2
         1: uid_3
   card_id_2
      usersWhoHaveSeenThisCard
         0: uid_1
         1: uid_3

Suppose we want to know which cards uid_2 has not seen - which in this case is card_id_2

func findCardsUserHasNotSeen(uidToCheck: String, completion: @escaping ( ([String]) -> Void ) ) {
    let ref = self.db.collection("cards")

    ref.getDocuments(completion: { snapshot, err in
        if let err = err {
            print(err.localizedDescription)
            return
        }

        guard let docs = snapshot?.documents else {
            print("no docs")
            return
        }
        var documentsIdsThatDoNotContainThisUser = [String]()
        for doc in docs {
            let uidArray = doc.get("usersWhoHaveSeenThisCard") as! [String]
            let x = uidArray.contains(uidToCheck)
            if x == false {
                documentsIdsThatDoNotContainThisUser.append(doc.documentID)
            }
        }
        completion(documentsIdsThatDoNotContainThisUser)
    })
}

Then, the use case like this

func checkUserAction() {
    let uid = "uid_2" //the user id to check
    self.findCardsUserHasNotSeen(uidToCheck: uid, completion: { result in
        if result.count == 0 {
            print("user: \(uid) has seen all cards")
            return
        }
        for docId in result {
            print("user: \(uid) has not seen: \(docId)")
        }
    })
}

and the output

user: uid_2 has not seen: card_id_2

This code goes through the documents, gets the array of uid's stored within each documents usersWhoHaveSeenThisCard node and determines if the uid is in the array. If not, it adds that documentID to the documentsIdsThatDoNotContainThisUser array. Once all docs have been checked, the array of documentID's that do not contain the user id is returned.

Knowing how fast Firestore is, I ran the code against a large dataset and the results were returned very quickly so it should not cause any kind of lag for most use cases.

Jay
  • 34,438
  • 18
  • 52
  • 81
  • Thanks Jay - I really like this approach. My only concern would be the potential for this to create an abundance of unnecessary reads. I'm not exactly sure how FireStore decides the order in which to fetch data, but it sure seems like the order is pretty similar (i.e.: cardUID₁, cardUID₂, etc). If this is the case, then users would have to for-in loop through everything they've seen before they see anything new, which will suck when they've gone through hundreds of cards. Let me know what you think - I really like this approach otherwise. – bhersh90 Sep 10 '19 at 21:35
  • I should clarify, I only want to pull a batch of 10 cards at a time and and currently have a .limit(10) clause in my fetch query – bhersh90 Sep 10 '19 at 21:41
  • @bhersh90 In this use case, the reads are necessary to determine which cards the user has not seen. If the dataset it s few thousand cards, it should be a minimal impact. As far as a limit that should be pretty straight forward to include a counter and bail out of the for loop when it reaches 10. – Jay Sep 11 '19 at 14:43
  • It costs one read per doc in the docs for-in loop, correct? If the average user has seen 90 cards and thus has to iterate through 100 documents to get to the batch of 10 new cards, then after 500 of these fetch requests, I'd hit my 50k read limit. On average, user's will call around 5 fetches per session, so that would support roughly 100 daily users before billing kicks in. Typing it out, that's actually not bad. I think I may switch over to this solution. Thanks again! – bhersh90 Sep 12 '19 at 23:47