Swift + Firebase: Storing, retrieving, and using an array of document references - swift

I'm relatively new to Swift and Firebase, so I'm not very familiar with the intricacies of how both work together. I'm building a chat app that has messages and threads. A user can send a message, struct Message, and if another user wants to directly reply to that message, a thread is created. For each message, I'm storing an array of Firebase document references to the other messages in the thread, threadBefore: [Message].
struct Message: Codable, Identifiable {
var id: String
var content: String
var name: String
var upvotes: Int
var likedByUser: Bool
var dontShow: Bool
var sentAt: Date
var threadsArray: [Message]
}
The following is my code for fetching all the chat messages from Firebase:
dontShow property: if dontShow == true means that the message is inside the thread and shouldn't be displayed like a regular message in the chat. However, the very last message in the thread is displayed and has dontShow = false.
func fetchMessages(docId: String, collectionType: String, isThreadMember: Bool) {
if (user != nil) {
db.collection("chatrooms").document(docId).collection(collectionType).order(by: "sentAt", descending: false).addSnapshotListener { (snapshot, error) in
guard let documents = snapshot?.documents else {
print("No messages")
return
}
// let threadsTemp: [Message]()
let classroomId = docId
if !isThreadMember {
if collectionType == "messages" {
self.messages = documents.map { doc -> Message in
let data = doc.data()
let docId1 = doc.documentID
let content = data["content"] as? String ?? ""
let displayName = data["displayName"] as? String ?? ""
let likedUsersArr = data["likedUsers"] as? Array ?? [""]
// if message is in thread (but not last message), then don't show as normal message, but in thread
let dontShow = data["dontShow"] as? Bool ?? false
let sentAt = data["sentAt"] as? Date ?? Date()
let threadBefore = data["threadBefore"] as? [DocumentReference] ?? [DocumentReference]()
// using reference array
if dontShow == false {
if (threadBefore.count > 0) {
// reset the temporary array that holds the threads to be added afterwards
self.threadsTemp = []
for docRef in threadBefore {
docRef.getDocument { (doc2, error) in
if let doc2 = doc2, doc2.exists {
let docId2 = doc2.documentID
self.fetchThreadMessages(classroomId: classroomId, parentMessageId: docId1, docId: docId2)
} else {
print("Document does not exist")
}
}
}
}
}
return Message(id: docId1,
content: content,
name: displayName,
upvotes: likedUsersArr.count,
likedByUser: likedUsersArr.contains(self.user!.uid) ? true : false,
dontShow: dontShow,
sentAt: sentAt,
threadsArray: self.threadsTemp)
}
}
Another function: fetchThreadMessages:
// fetch a specified message and then append to the temporary threads array, threadsTemp
func fetchThreadMessages(classroomId: String, parentMessageId: String, docId: String) -> Message {
if (user != nil) {
let docRef = db.collection("chatrooms").document(classroomId).collection("messages").document(docId)
docRef.getDocument { (doc, error) in
if let doc = doc, doc.exists {
if let data = doc.data(){
let docId = doc.documentID
print("docid")
print(docId)
let content = data["content"] as? String ?? ""
let displayName = data["displayName"] as? String ?? ""
let likedUsersArr = data["likedUsers"] as? Array ?? [""]
// if message is in thread (but not last message), then don't show as normal message, but in thread
let dontShow = data["dontShow"] as? Bool ?? false
let sentAt = data["sentAt"] as? Date ?? Date()
self.threadsTemp.append(Message(id: docId,
content: content,
name: displayName,
upvotes: likedUsersArr.count,
likedByUser: likedUsersArr.contains(self.user!.uid) ? true : false,
dontShow: true,
sentAt: sentAt,
threadsArray: []))
}
}
}
}
}
I haven't implemented how the sendMessage() function updates the threadBefore array, but I'm currently updating this field directly on Firebase just for testing.
func sendMessage(messageContent: String, docId: String, collectionType: String, isReply: Bool, threadId: String) {
if (user != nil) {
if isReply {
let docRef = db.collection("chatrooms").document(docId).collection(collectionType).document(threadId)
self.threadRef = db.document(docRef.path)
}
db.collection("chatrooms").document(docId).collection(collectionType).addDocument(data: [
"sentAt": Date(),
"displayName": user!.email ?? "",
"content": messageContent,
"likedUsers": [String](),
"sender": user!.uid,
"threadBefore": isReply ? [threadRef] : [DocumentReference](),
"dontShow": false])
}
}
A little bit more on how I'm fetching and retrieving the document references from threadsBefore: For each message in the collection, I loop its threadsArray, which consists of DocumentReferences to other messesages that are in that thread. For each of those document references, I run self.fetchThreadMessages. This retrieves that message and stores a Message() instance in threadsTemp. Then, back in self.fetchMessage, when I'm done filling up the self.threadsTemp with all of the documents retrieved from threadsBefore, I store it in threadsArray property in the Message struct.
Now, look at the return state in self.fetchMessages above, the very last assignment inside Message() is threadsArray: self.threadsTemp. But the problem here is that this is just a reference? And it would change based on the last assignment to self.threadsTemp?
I've tried multiple ways to implement this entire storing and retrieving thing. But all came with several complicated errors. I tried using dictionaries, or storing just the document id's for the thread messages and then look them up in self.Messages (since it has all of the messages stored in it).
What's the best way to implement this? Or fix my errors?
I know my code is probably a mishmash of inefficient and confused coding practices. But I'm trying to learn.

With that being said (meaning, my comment above), I'm going to not as much address your code as I am going to propose what I think is a good way to organize your Firestore database for a chat app. Then, hopefully you will be able to apply it to your own situation.
First things first, we have to remember that Firestore charges based on the number of queries you make - it does not take into account the amount of data that is being fetched in a particular query - so fetching a single word is as expensive as fetching an entire novel. As such, we should structure our database in a way that is financially efficient.
What I would do personally is structure my database with two primary sections, (1) conversation threads and (2) users. Each user contains references to their threads they are a part of, and each thread contains a reference to the users that are in that thread. That way we can easily and efficiently fetch a user's conversations, or obtain the users in a particular conversation.
// COL = Collection
// DOC = Document
Threads (COL)
Thread1 (DOC)
id: String // id of the thread
participants: [String: String] = [
user1: [
"username": "Bob23",
"userID": IJ238KD... // the document id of the user
]
] // Keep this "participants" dictionary in the root of the thread
// document so we have easy access to fetch any user we want, if
// we so desire (like if the user clicks to view the profile of a
// user in the chat.
Messages (COL)
Message1 (DOC)
from: String // id of user that sent message
Message2 (DOC)
...
...
Thread 2 (DOC)
...
...
Users (COL)
User1 (DOC)
threadIDs: [String] // Contains the thread "id"s of all the threads
// the user is a part of. (You probably want to
// use a dictionary instead since array's can be
// difficult to work with in a database, but for
// simplicity, I'm going to use an array here.)
User2 (DOC)
...
...
Now, let's say the user opens the app and we need to fetch their conversation threads. It's as easy as
db.collection("Threads").whereField("id", in: user.threadIDs)
[EDIT] user.threads would be a local array of type String. You should have this data on hand since you would be fetching the current user's User document on app launch, which is why we include this data in that user document.
Basically, this query returns every document in the Threads collection whose "id" field exists in the array of the user's threadIDs (which is an array of type String). You can read more about Firestore queries in their docs here.
In other words, we get every thread document that the user has references to.
The great thing about this is that it takes only one query to return all of the conversations of a user.
Now let's say the user is scrolling through their conversation threads in the app, and they tap one of them to open up the messages. Now, all we do is fetch all the messages in that particular thread, again only requiring one query to do so.
Last but not least, if for some reason we have to get info about a particular user in a conversation, we have all the references we need within that thread document itself to fetch the user data, if needed.

Related

Bypassing Firebase IN: limit in Swift

I have a query that I am doing with Google's Firestore where I get a query of posts from a collection. This is a central collection of all posts on the network but I want to be able to filter what comes from the server so the processing/filtering doesn't happen on the client side. Essentially, I want to get a feed of posts from only account the user follows.
Creating that array is easy enough. When the user logins it downloads the array of UID the user follows. This array, theoretically, could be long. Firestore has this handy '.whereField' option that can filter look through a String Array using in: like this.
.whereField("userUID", in: auth.userFollowedAccounts!)
It works perfectly but according to the documentation Firestore only allowed 10 items in the array when using in:. This causes an issues because I want users to be able to follow more then 10 accounts. I saw some other solutions from some other languages to get around this issue by splicing the array or doing some clever looping to go though all the options. Unfortunately, I can't seem to find a solution for Swift. Would love someone to take a look and help me brainstorm some work arounds for this.
// Full Code Block
func getData() {
let db = Firestore.firestore()
db.collection("posts")
.order(by: "dateCreated", descending: true)
.whereField("userUID", in: auth.userFollowedAccounts!)
.limit(to: 10)
.getDocuments { (snap, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
self.post.removeAll()
for i in snap!.documents {
let data = Post(id: i.documentID, username: i.get("username") as? String, fullName: i.get("fullName") as? String, profilePictureURL: i.get("profilePictureURL") as? String, link: i.get("link") as? String, location: i.get("location") as? String, postImage: i.get("postImage") as? String, isVerified: i.get("isVerified") as? Bool, caption: i.get("caption") as? String, likeCounter: i.get("likeCounter") as? Int, dateCreated: i.get("dateCreated") as? String, userUID: i.get("userUID") as? String, isAdmin: i.get("isAdmin") as? Bool, pronouns: i.get("pronouns") as? String)
self.post.append(data)
}
self.lastDoc = snap!.documents.last
}
}
Let me know if you have any questions.
Here's a simple example using Firestore async/await calls to shorten the code.
The Firebase structure was not included in the question (please include that in questions) so I will craft one which may be similar to what you're using
Starting with a users collection which uses the users uid as the documentId, keeps the users name and then the users who they are following as an array
users
uid_0
user_name: "Leroy"
following
0: uid_9
uid_1
user_name: "Biff"
following
0: uid_0
1: uid_2
In this, uid_0 is following uid_9 and uid_1 is following uid_0 and uid_2
Then a posts collection snippit
posts
post_0
post_msg: "Post 0 from uid_0"
post_uid: "uid_0"
post_1
post_msg: "Post 0 from uid_1"
post_uid: "uid_1"
post_2
post_msg: "Post 1 from uid_0"
post_uid: "uid_0"
The posts have a message and the uid of the user that posted it.
Then the code.
func getPostsOfUsersIFollow() {
let usersCollection = self.db.collection("users")
let postsCollection = self.db.collection("posts")
let thisUserDoc = usersCollection.document("uid_1") //me
Task {
do {
let userResult = try await thisUserDoc.getDocument()
let usersIFollow = userResult.get("following") as? [String] ?? []
print(usersIFollow) //outputs the list of users uid's I follow
for uid in usersIFollow {
let usersPosts = try await postsCollection.whereField("post_uid", isEqualTo: uid).getDocuments()
for postDoc in usersPosts.documents {
let postMsg = postDoc.get("post_msg") as? String ?? "no msg"
print("post_id: \(postDoc.documentID) uid: \(uid) posted msg: \(postMsg)")
}
}
} catch {
print("need to handle error")
}
}
}
If I am user uid_1, I am following uid_0 and uid_2. When this code is run, it will first query for all of the users I am following (uid_0, uid_2) then iterate over that list, which can be any number of users, to query the posts from each of those users, and output those posts to console.
So, if uid_0 has 3 posts and uid_2 has 3 posts the final output would look like this
["uid_0", "uid_2"]
post_id: post_0 uid_0 posted msg: Post 0 from uid_0
post_id: post_4 uid_0 posted msg: Post 1 from uid_0
post_id: post_6 uid_0 posted msg: Post 2 from uid_0
post_id: post_2 uid_2 posted msg: Post 0 from uid_2
post_id: post_5 uid_2 posted msg: Post 1 from uid_2
post_id: post_9 uid_2 posted msg: Post 2 from uid_2
In this case I output to console but in code, you'd likely have some class to store the uid, name and post and then populate an array which backs a tableView or collection with that data
class UserPostClass {
var uid = ""
var postId = ""
var userName = ""
var postMsg = ""
}
var userPostArray = [UserPostClass]()
and then once the array was populated, reload your tableView, view etc.
The one gotcha here is ensuring the UI is responsive - with small datasets this will work as is, but if you're loading thousands of posts (don't do that) you'll likely want to paginate your data to break it into smaller chunks.
The other thing to note is there is no ordering, so you'll likely want to add an orderBy clause

How can I make retrieving data from firestore cloud faster when refreshing the page [duplicate]

I am new in programming and in iOS development. I am trying to make an app using Firestore database from Firebase. I don't know if it is normal or not, but when I am trying to get a data from firestore database, it seems too long for me. I don't know if I make a mistake or not
here is my code to get all city data from firestore
reference :
import Foundation
import FirebaseFirestore
import Firebase
enum FirestoreCollectionReference {
case users
case events
case cities
private var path : String {
switch self {
case .users : return "users"
case .events : return "events"
case .cities : return "cities"
}
}
func reference () -> CollectionReference {
return Firestore.firestore().collection(path)
}
}
I use getAllCitiesDataFromFirestore method in CityKM class to get the city data that stored in firestore
class CityKM {
var name : String
var coordinate : GeoPoint
init (name: String , coordinate: GeoPoint ) {
self.name = name
self.coordinate = coordinate
}
init (dictionary: [String:Any]) {
// this init will be used if we get data from firebase observation to construct an event object
name = dictionary["name"] as! String
coordinate = dictionary["coordinate"] as! GeoPoint
}
static func getAllCitiesDataFromFirestore (completion: #escaping ( [CityKM]? )->Void) {
// to retrieve all cities data from Firebase database by one read only, not using realtime fetching listener
let startTime = CFAbsoluteTimeGetCurrent() // to track time consumption of this method
FirestoreCollectionReference.cities.reference().getDocuments { (snapshot, error) in
if let error = error {
print("Failed to retrieve all cities data: \(error.localizedDescription)")
} else {
print("Sucessfully get all cities data from firestore")
guard let documentsSnapshot = snapshot, !documentsSnapshot.isEmpty else {
completion(nil)
return
}
let citiesDocuments = documentsSnapshot.documents
var cityArray = [CityKM]()
for document in citiesDocuments {
guard let cityName = document.data()["name"] as? String,
let cityCoordinate = document.data()["coordinate"] as? GeoPoint else {return}
let theCity = CityKM(name: cityName, coordinate: cityCoordinate)
cityArray.append(theCity)
}
completion(cityArray)
let timeElapsed = CFAbsoluteTimeGetCurrent() - startTime // to track time consumption of this method
print("Time needed to get all cities data from Firestore : \(timeElapsed) s.") // to track time consumption of this method
}
}
}
}
extension CityKM {
// MARK: - User Helper Methods
func toDictionary() -> [String:Any]{
return [
"name" : name,
"coordinate" : coordinate
]
}
}
from my debugging area, it is printed
"Time needed to get all cities data from Firestore : 1.8787678903 s."
is it possible to make it faster? Is 1.8s normal? am i make a mistake in my code that make the request data takes too long time ? I hope that I can make request time is below one second
I don't think the internet speed is the problem, since I can open video on youtube without buffering
That performance sounds a bit worse than what I see, but nothing excessive. Loading data from the cloud simply takes time. A quick approach to hide that latency is by making use of Firebase's built-in caching.
When you call getDocuments, the Firebase client needs to check on the server what the document's value is before it can call your code, which then shows the value to the user. As said: there is no way to speed up this reading in your code, so it'll always take at least 1.8s before the user sees a document.
If instead you listen for realtime updates from the database with addSnapshotListener, the Firebase client may be able to immediately call your code with values from its local cache, and then later re-invoke your code in case there has been an update to the data on the server.

Reading Firestore Document containing an array of references

Thanks in advance for the help. I'm teaching myself Swift and trying to figure out how to retrieve the following data from Firebase. Here's my Firebase Data Model...
Groups (Collection)
-> GroupName (String)
-> Owner (References to someone in the Players collection)
Players (Collection)
-> PlayerFirstName
-> PlayerLastName
The Swift I've written to retrieve this data is in a ViewModel. getAllGroups is called from onAppear in the View and looks like this...
class Group: Identifiable, ObservableObject {
var id: String = UUID().uuidString
var name: String?
var owner: Player?
}
class GroupViewModel: ObservableObject {
#Published var groups = [Group]()
private var db = Firestore.firestore()
func getAllGroups() {
db.collection("groups").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No groups")
return
}
self.groups = documents.map { (queryDocumentSnapshot) -> Group in
var group = Group()
let data = queryDocumentSnapshot.data()
group.name = data["name"] as? String ?? ""
//
// LIKE --- SHOULD THIS CALL TO GETPLAYER use AWAIT, FOR EXAMPLE?
// WE'RE EXECUTING THE CLOSURE FOR THE FIRST CALL AND ABOUT TO MAKE A SECOND
//
group.owner = self.getPlayer(playerRef: data["owner"] as! DocumentReference)
return group
}
}
}
func getPlayer(playerRef: DocumentReference) -> Player {
var player = Player()
playerRef.getDocument { (document, error) in
guard error == nil else {
print ("error", error ?? "")
return
}
if let document = document, document.exists {
let data = document.data()
if let data = data {
player.firstName = data["firstname"] as? String
player.lastName = data["lastname"] as? String
}
}
}
return player
}
}
The sorta obvious problem here is the closure for retrieving the parent Group executes and then goes and tries to retrieve the Owner. But by the time the closure inside getPlayer completes... the Group has already been established.
Groups will have...
group[0]
-> GroupName = "Cool Name Here"
-> Owner = nil
group[0]
-> GroupName = "Different Cool Name"
-> Owner = nil
even though each Group definitely has an Owner.
I get there's some stuff here about asynchronous calls in Swift and how best to handle that... I'm just not sure what the proper pattern is. Thanks again for the help and advice!
-j
To restate the question:
How do you nest Firestore functions
There are 100 ways to do it and, a lot of it depends on the use case. Some people like DispatchGroups, others like escaping completion handlers but in a nutshell, they pretty much do the "same thing" as the following code, written out "long hand" for readability
func populateGroupArray() {
db.collection("groups").addSnapshotListener { (querySnapshot, error) in
guard let docs = querySnapshot?.documents else { return }
for doc in docs {
let groupName = doc.get("name") as! String
let ownerId = doc.get("owner_id") as! String
self.addToArray(groupName: groupName, andOwnerId: ownerId)
}
}
}
func addToArray(groupName: String, andOwnerId: String) {
db.collection("owners").document(andOwnerId).getDocument(completion: { snapshot, error in
let name = snapshot?.get("owner_name") as! String
let group = Group(groupName: groupName, ownerName: name)
self.groups.append(group)
})
}
In summary; calling populateGroupArray reads in all of the documents from the groups collection from Firestore (adding a listener too). We then iterate over the returned documents to get each group name and the owner id of the group.
Within that iteration, the group name and ownerId are passed to another function that reads in that specific owner via it's ownerId and retrieves the name
Finally, a Group object is instantiated with groupName and owner name being populated. That group is then added to a class var groups array.
Now, if you ask a Firebaser about this method, they will generally recommend not reading large amounts of Firebase data 'in a tight loop'. That being said, this will work very well for many use cases.
In the case you've got a HUGE dataset, you may want to consider denormalizing your data by including the owner name in the group. But again, that would be a rare situation.

Removing an array item from Firestore not working when array contains date

I've spent days researching this including various answers like: Firebase Firestore: Append/Remove items from document array and my previous question at: Removing an array item from Firestore
but can't work out how to actually get this working. Turns out the issue is when there is a date property in the object as shown below:
I have two structs:
struct TestList : Codable {
var title : String
var color: String
var number: Int
var date: Date
var asDict: [String: Any] {
return ["title" : self.title,
"color" : self.color,
"number" : self.number,
"date" : self.date]
}
}
struct TestGroup: Codable {
var items: [TestList]
}
I am able to add data using FieldValue.arrayUnion:
#objc func addAdditionalArray() {
let testList = TestList(title: "Testing", color: "blue", number: Int.random(in: 1..<999), date: Date())
let docRef = FirestoreReferenceManager.simTest.document("def")
docRef.updateData([
"items" : FieldValue.arrayUnion([["title":testList.title,
"color":testList.color,
"number":testList.number,
"date": testList.date]])
])
}
The above works as reflected in the Firestore dashboard:
But if I try and remove one of the items in the array, it just doesn't work.
#objc func deleteArray() {
let docRef = FirestoreReferenceManager.simTest.document("def")
docRef.getDocument { (document, error) in
do {
let retrievedTestGroup = try document?.data(as: TestGroup.self)
let retrievedTestItem = retrievedTestGroup?.items[1]
guard let itemToRemove = retrievedTestItem else { return }
docRef.updateData([
"items" : FieldValue.arrayRemove([itemToRemove.asDict])
]) { error in
if let error = error {
print("error: \(error)")
} else {
print("successfully deleted")
}
}
} catch {
}
}
}
I have printed the itemToRemove to the log to check that it is correct and it is. But it just doesn't remove it from Firestore. There is no error returned, yet the "successfully deleted" is logged.
I've tried different variations and this code works as long as I don't have a date property in the struct/object. The moment I add a date field, it breaks and stops working. Any ideas on what I'm doing wrong here?
Please note: I've tried passing in the field values as above in FieldValue.arrayUnion as well as the object as per FieldValue.arrayRemove and the same issue persists regardless of which method I use.
The problem is, as you noted, the Date field. And it's a problem because Firestore does not preserve the native Date object when it's stored in the database--they are converted into date objects native to Firestore. And the go-between these two data types is a token system. For example, when you write a date to Firestore from a Swift client, you actually send the database a token which is then redeemed by the server when it arrives which then creates the Firestore date object in the database. Conversely, when you read a date from Firestore on a Swift client, you actually receive a token which is then redeemed by the client which you then can convert into a Swift Date object. Therefore, the definition of "now" is not the same on the client as it is on the server, there is a discrepancy.
That said, in order to remove a specific item from a Firestore array, you must recreate that exact item to give to FieldValue.arrayRemove(), which as you can now imagine is tricky with dates. Unlike Swift, you cannot remove items from Firestore arrays by index. Therefore, if you want to keep your data architecture as is (because there is a workaround I will explain below), the safest way is to get the item itself from the server and pass that into FieldValue.arrayRemove(). You can do this with a regular read and then execute the remove in the completion handler or you can perform it atomically (safer) in a transaction.
let db = Firestore.firestore()
db.runTransaction { (trans, errorPointer) -> Any? in
let doc: DocumentSnapshot
let docRef = db.document("test/def")
// get the document
do {
try doc = trans.getDocument(docRef)
} catch let error as NSError {
errorPointer?.pointee = error
return nil
}
// get the items from the document
if let items = doc.get("items") as? [[String: Any]] {
// find the element to delete
if let toDelete = items.first(where: { (element) -> Bool in
// the predicate for finding the element
if let number = element["number"] as? Int,
number == 385 {
return true
} else {
return false
}
}) {
// element found, remove it
docRef.updateData([
"items": FieldValue.arrayRemove([toDelete])
])
}
} else {
// array itself not found
print("items not found")
}
return nil // you can return things out of transactions but not needed here so return nil
} completion: { (_, error) in
if let error = error {
print(error)
} else {
print("transaction done")
}
}
The workaround I mentioned earlier is to bypass the token system altogether. And the simplest way to do that is to express time as an integer, using the Unix timestamp. This way, the date is stored as an integer in the database which is almost how you'd expect it to be stored anyway. This makes locating array elements that contain dates simpler because time on the client is now equal to time on the server. This is not the case with tokens because the actual date that is stored in the database, for example, is when the token is redeemed and not when it was created.
You can extend Date to conveniently convert dates to timestamps and extend Int to conveniently convert timestamps to dates:
typealias UnixTimestamp = Int
extension Date {
var unixTimestamp: UnixTimestamp {
return UnixTimestamp(self.timeIntervalSince1970 * 1_000) // millisecond precision
}
}
extension UnixTimestamp {
var dateObject: Date {
return Date(timeIntervalSince1970: TimeInterval(self / 1_000)) // must take a millisecond-precision unix timestamp
}
}
One last thing is that in my example, I located the element to delete by its number field (I used your data), which I assumed to be a unique identifier. I don't know the nature of these elements and how they are uniquely identified so consider the filter predicate in my code to be purely an assumption.

How to make a function with a loop asynchronous in Swift?

I am creating an application for a library. I am trying to fetch all the books the user has checked out from Firebase, but my attempts to make the function asynchronous with a DispatchGroup doesn't seem to be working. I suspect this to be because of the for-in loop found inside of the function.
func fetchHistory() {
if items.count > 0 {
items.removeAll()
}
let myGroup = DispatchGroup()
myGroup.enter()
var itemNames = [String]() // this holds the names of the child values of /users/uid/items/ <-- located in Firebase Database
guard let uid = fAuth.currentUser?.uid else {return}
fData.child("users").child(uid).child("items").observe(.value, with: { snapshot in
// make sure there is at least ONE item in the history
if snapshot.childrenCount > 0 {
let values = snapshot.value as! NSDictionary
for i in values.allKeys {
itemNames.append(i as! String)
}
print(itemNames)
let uid = fAuth.currentUser!.uid // get the UID of the user
for item in itemNames {
fData.child("users").child(uid).child("items").child(item).observe(.value, with: { snapshot in
let values = snapshot.value as! NSDictionary
let bookTitle = values["title"] as! String
print(bookTitle)
let bookAuthor = values["author"] as! String
print(bookAuthor)
let bookCoverUrl = values["coverUrl"] as! String
print(bookCoverUrl)
let bookStatus = values["status"] as! String
print(bookStatus)
let bookDueDate = values["dueDate"] as! String
print(bookDueDate)
let book = Book(name: bookTitle, author: bookAuthor, coverUrl: bookCoverUrl, status: bookStatus, dueDate: bookDueDate)
self.items.append(book)
})
}
self.booksTable.isHidden = false
} else {
self.booksTable.isHidden = true
}
})
myGroup.leave()
myGroup.notify(queue: DispatchQueue.main, execute: {
self.booksTable.reloadData()
print("Reloading table")
})
}
Here is the output from the print() statements:
########0
Reloading table
["78DFB90A-DE5B-47DE-ADCA-2DAB9D43B9C8"]
Mockingjay (The Hunger Games, #3)
Suzanne Collins
https://images.gr-assets.com/books/1358275419s/7260188.jpg
Checked
Replace
The first two lines of output should be printed AFTER everything else has printed. I really need some help on this, I have been stuck on this for hours. Thanks!
Edit:
As requested, here is my Firebase structure:
users:
meZGWn5vhzXpk5Gsh92NhSasUPx2:
ID: "12345"
firstname: "Faraaz"
items:
78DFB90A-DE5B-47DE-ADCA-2DAB9D43B9C8
author: "Suzanne Collins"
coverUrl: "https://images.gr assets.com/books/1358275419s/..."
dueDate: "Date"
status: "Checked"
title: "Mockingjay (The Hunger Games, #3)"
type: "regular"
A couple of issues:
The pattern is that leave must be called inside the completion handler of the asynchronous call. You want this to be the last thing performed inside the closure, so you could add it as the the last line within completion handler closure.
Or I prefer to use a defer clause, so that not only do you know it will be the last thing performed in the closure, but also:
you ensure you leave even if you later add any "early exits" inside your closure; and
the enter and leave calls visually appear right next to each other in the code saving you from having to visually hunt down at the bottom of the closure to make sure it was called correctly.
You also, if you want to wait for the asynchronous calls in the for loop, have to add it there, too.
A very minor point, but you might want to not create the group until you successfully unwrapped uid. Why create the DispatchGroup if you could possibly return and not do any of the asynchronous code?
Thus, perhaps:
func fetchHistory() {
if items.count > 0 {
items.removeAll()
}
var itemNames = [String]()
guard let uid = fAuth.currentUser?.uid else {return}
let group = DispatchGroup()
group.enter()
fData.child("users").child(uid).child("items").observe(.value, with: { snapshot in
defer { group.leave() } // in case you add any early exits, this will safely capture
if snapshot.childrenCount > 0 {
...
for item in itemNames {
group.enter() // also enter before we do this secondary async call
fData.child("users").child(uid).child("items").child(item).observe(.value, with: { snapshot in
defer { group.leave() } // and, again, defer the `leave`
...
})
}
...
} else {
...
}
})
group.notify(queue: .main) {
self.booksTable.reloadData()
print("Reloading table")
}
}
While there is a brilliant answer from Rob, I would approach a solution from a different direction.
A book can only ever had one person check it out (at a time), but a borrower can have multiple books. Because of that relationship, simply combine who has the book with the book itself:
Here's a proposed users structure
users
uid_0
name: "Rob"
uid_1
name: "Bill"
and then the books node
books
78DFB90A-DE5B-47DE-ADCA-2DAB9D43B9C8
author: "Suzanne Collins"
coverUrl: "https://images.gr assets.com/books/1358275419s/..."
dueDate: "Date"
status: "Checked"
title: "Mockingjay (The Hunger Games, #3)"
checked_out_by: "uid_0"
check_date: "20180118"
Then to get ALL of the books that Rob has checked out and use those results to populate an array and display it in a tableview becomes super simple:
//var bookArray = [Book]() //defined as a class var
let booksRef = self.ref.child("books")
let query = booksRef.queryOrdered(byChild: "checked_out_by").queryEqual(toValue: "uid_0")
booksRef.observeSingleEvent(of: .value, with: { snapshot in
for child in snapshot.children {
let snap = child as! DataSnapshot
let book = Book(initWithSnap: snap) //take the fields from the snapshot and populate the book
self.bookArray.append(book)
}
self.tableView.reloadData()
})
But then you ask yourself, "self, what if I want a record of who checked out the book?"
If you need that functionality, just a slight change to the books node so we can leverage a deep query;
books
78DFB90A-DE5B-47DE-ADCA-2DAB9D43B9C8
author: "Suzanne Collins"
coverUrl: "https://images.gr assets.com/books/1358275419s/..."
dueDate: "Date"
status: "Checked"
title: "Mockingjay (The Hunger Games, #3)"
check_out_history
"uid_0" : true
"uid_1" : true
and move the check out dates to the users node. Then you can query for any user of any book and have history of who checked out that book as well. (there would need to be logic to determine who has the book currently so this is just a starting point)
Or if you want another option, keep a separate book history node
book_history
78DFB90A-DE5B-47DE-ADCA-2DAB9D43B9C8
-9j9jasd9jasjd4 //key is created with childByAutoId
uid: "uid_0"
check_out_date: "20180118"
check_in_date: "20180122"
condition: "excellent"
-Yuhuasijdijiji //key is created with childByAutoId
uid: "uid_1"
check_out_date: "20180123"
check_in_date: "20180125"
condition: "good"
The concept is to let Firebase do the work for you instead of iterating over arrays repeatedly and having to issue dozens of calls to get the data you need. Adjusting the structure makes it much simpler to maintain and expand in the future as well - and it avoids all of the issues with asynchronous code as it's all within the closure; nice and tidy.