skip to Main Content

I am writing an iOS app in Swift, using Firestore as a database. I have classes representing my Firestore objects that look like this:

class FirestoreObject: Decodable, Hashable, ObservableObject {
    
    enum CodingKeys: String, CodingKey {
        case id
        case attributeA = "attribute_a"
        case attributeB = "attribute_b"
    }
    
    @DocumentID var id: String?
    @Published var attributeA: Double
    @Published var attributeB: String
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        _id = try container.decode(DocumentID<String>.self, forKey: .id)
        self.attributeA = try container.decode(Double.self, forKey: .attributeA)
        self.attributeB = try container.decode(String.self, forKey: .attributeB)
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: FirestoreObject, rhs: FirestoreObject) -> Bool {
        return lhs.id == rhs.id
    }
    
}

This pattern is the same for all of my Firestore objects. How can I generalize this to avoid repeating myself? The hash and the == functions can always be exactly the same, and the init(from decoder:) will always work exactly the same, although of course the keys/attributes differ from object to object.

I looked into protocols and inheritance, but I’m still new to Swift and I’m not sure the right way to go about it. The main thing is trying to automatically provide the default init that works the way I want to — it’s pretty much the same as the default init(from: decoder) that swift provides for Decodable.

2

Answers


  1. I think the best solution here is to add a super class that all your Firestore types inherits from

    class SuperFirestoreObject: ObservableObject, Decodable {
        @DocumentID var id: String?
        
        enum CodingKeys: String, CodingKey { case id }
        
        required init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            _id = try container.decode(DocumentID.self, forKey: .id)
        }
        
        func hash(into hasher: inout Hasher) {
            hasher.combine(id)
        }
        
        static func == (lhs: SuperFirestoreObject, rhs: SuperFirestoreObject) -> Bool {
            return lhs.id == rhs.id
        }
    }
    

    Then you will need to add a init(from:) in all the subclasses to decode the specific attributes for that sub class

    class FirestoreObject: SuperFirestoreObject {
        @Published var attributeA: Double
        @Published var attributeB: String
    
        enum CodingKeys: String, CodingKey {
            case attributeA = "attribute_a"
            case attributeB = "attribute_b"
        }
    
        required init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            self.attributeA = try container.decode(Double.self, forKey: .attributeA)
            self.attributeB = try container.decode(String.self, forKey: .attributeB)
            try super.init(from: decoder)
        }
    }
    

    I don’t think you can generalise it much more than that unless you could use struct and wrap them in a generic class that conforms to ObservableObject but then only the object would be published and not the individual properties.

    Something like

    class FirestoreObjectHolder<FirestoreObject>: ObservableObject where FirestoreObject: Hashable, FirestoreObject: Decodable {
        @Published var object: FirestoreObject
    
        init(object: FirestoreObject) {
            self.object = object
        }
    }
    

    Then the actual type would be quite easy to implement

    struct FirestoreObjectAsStruct: Hashable, Decodable {
        @DocumentID var id: String?
        let attributeA: Double
        let attributeB: String
    
        enum CodingKeys: String, CodingKey {
            case attributeA = "attribute_a"
            case attributeB = "attribute_b"
        }
    }
    
    Login or Signup to reply.
  2. Firestore supports Swift’s Codable protocol, which makes mapping a lot easier: For most cases, you won’t have to write any mapping code. Only if you have special requirements (e.g. mapping only some document attributes, or mapping attributes to property with a slightly different name), you will have to add a few lines of code to tell Codable which fields to map, or which properties to map to.

    Our documentation has a comprehensive guide that explains the basics of Codable, and some of the more advanced use cases: Map Cloud Firestore data with Swift Codable  |  Firebase

    In a SwiftUI app, you will want your data model to be structs, and only the view model should be a class conforming to ObservableObject.

    Here is an example:

    Data model

    struct ProgrammingLanguage: Identifiable, Codable {
      @DocumentID var id: String?
      var name: String
      var year: Date
      var reasonWhyILoveThis: String = ""
    }
    
    

    View Model

    class ProgrammingLanguagesViewModel: ObservableObject {
      @Published var programmingLanguages = [ProgrammingLanguage]()
      @Published var newLanguage = ProgrammingLanguage.empty
      @Published var errorMessage: String?
      
      private var db = Firestore.firestore()
      private var listenerRegistration: ListenerRegistration?
      
      fileprivate  func unsubscribe() {
        if listenerRegistration != nil {
          listenerRegistration?.remove()
          listenerRegistration = nil
        }
      }
      
      fileprivate func subscribe() {
        if listenerRegistration == nil {
          listenerRegistration = db.collection("programming-languages")
            .addSnapshotListener { [weak self] (querySnapshot, error) in
              guard let documents = querySnapshot?.documents else {
                self?.errorMessage = "No documents in 'programming-languages' collection"
                return
              }
              
              self?.programmingLanguages = documents.compactMap { queryDocumentSnapshot in
                let result = Result { try queryDocumentSnapshot.data(as: ProgrammingLanguage.self) }
                
                switch result {
                case .success(let programmingLanguage):
                  // A ProgrammingLanguage value was successfully initialized from the DocumentSnapshot.
                  self?.errorMessage = nil
                  return programmingLanguage
                case .failure(let error):
                  self?.errorMessage = "Error decoding document: (error.localizedDescription)"
                  return nil
                }
              }
            }
        }
      }
      
      fileprivate func addLanguage() {
        let collectionRef = db.collection("programming-languages")
        do {
          let newDocReference = try collectionRef.addDocument(from: newLanguage)
          print("ProgrammingLanguage stored with new document reference: (newDocReference)")
        }
        catch {
          print(error)
        }
      }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search