2

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:

enter image description here

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.

1 Answer 1

4

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.

Sign up to request clarification or add additional context in comments.

3 Comments

OMG Legend. I will update my code and test this out ad revert. Thank you. The app I’m building uses dates/times and timezones a lot so is the integer approach a better way of dealing with dates/times across swift and Firestore?
I wouldn’t say there is a better way, I’d say there is mostly preference for one over the other. The Unix timestamp approach is undoubtedly more universal; in fact, it’s completely universal as every database takes integers. The timestamp approach also simplifies things because it eliminates the token system which is an extra layer to account for that you don’t have to account with any other data type. Personally, I prefer the timestamp approach because I prefer as much simplicity as I can get when programming.
I've retrofitted the above suggested code (as a transaction) and it works. Thank you. If I'm understanding this correctly, then the key here is not to convert it back to a local swift object (TestGroup.self) but rather simply use [[String: Any]] as you've suggested. If that's the case, then does it still need to be done as a transaction? I'm not understanding that part I think. Regarding your other point re timestamp vs. Unix timestamp integers. I agree with you regarding the simplicity and am worried I'll lose other data conversions/timezone benefits if I move away from timestamps.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.