Firestoreでドキュメント内のコレクションを監視する

# はじめに

Firestoreでは`Codable`に準拠したstructを用いることで簡単にデータの読み書きを行うことができます。また、Firestore上での変更を監視することも可能です。

しかし、structの配列を持つ場合、**簡単に監視をすることができません。**

以前ここですごく詰まったので、記事にまとめます。

# 現状の構造

`User`が`Score`の配列を持つものとします。

```swift
struct User: Codable {
    @DocumentID var id: String?
    var name: String
    var email: String
    var scores: [Score]
}

struct Score: Codable {
    @DocumentID var id: String?
    var score: Int
}
```

また、監視のための`addSnapshotListener`をこのように書いているとします。

```swift
listener = db.collection("users").addSnapshotListener { snapshot, error in
    self.users = snapshot!.documents.compactMap { document in
        try? document.data(as: User.self)
    }
}
```

Firestore上の構成はこのようになっているとします。

![スクリーンショット 2024-12-16 18.09.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823873/fce6f920-6d64-1761-ab1a-300cf4ebe178.png)

しかし、このように取得すると取得ができず、エラーになってしまいます。

# 原因

documentの中にあるcollectionは、**Codableの配列として扱われません！**

もし`scores`も`users`の監視で取得したければ、以下のような構成にしなければなりません。

![スクリーンショット 2024-12-16 18.10.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823873/4ecef5be-6128-37b9-2144-db3bc7277ddc.png)

しかしこれだと、documentの中にcollectionが作れる良さが薄れますね…

# 改善案

ということで、usersの`addSnapshotListener`の時に、各scoresにも`addSnapshotListener`をしましょう。


```swift
struct User: Codable, Identifiable {
    @DocumentID var id: String?
    var name: String
    var email: String
    var scores: [Score] = [] //こうすることでinit時にエラーが起こらない
}

struct Score: Codable {
    @DocumentID var id: String?
    var score: Int
}
```

```swift

class UsersViewModel: ObservableObject {
    @Published var users: [User] = []
    private var db = Firestore.firestore()
    private var userListeners: [ListenerRegistration] = []
    private var mainListener: ListenerRegistration?
    
    init() {
        fetchUsers()
    }
    
    func fetchUsers() {
        mainListener = db.collection("users").addSnapshotListener { snapshot, error in
            if let error = error {
                print("Error fetching users: \(error.localizedDescription)")
                return
            }
            
            guard let documents = snapshot?.documents else {
                print("No documents found")
                return
            }
            
            self.users = documents.compactMap { document in
                try? document.data(as: User.self)
            }
            
            self.removeUserListeners()
            for user in self.users {
                if let userID = user.id {
                    self.fetchScores(for: userID)
                }
            }
        }
    }
    
    func fetchScores(for userID: String) {
        let listener = db.collection("users").document(userID).collection("scores").addSnapshotListener { snapshot, error in
            if let error = error {
                print("Error fetching scores for user \(userID): \(error.localizedDescription)")
                return
            }
            
            guard let documents = snapshot?.documents else {
                print("No scores found for user \(userID)")
                return
            }
            
            let scores = documents.compactMap { document in
                try? document.data(as: Score.self)
            }
            
            DispatchQueue.main.async {
                if let index = self.users.firstIndex(where: { $0.id == userID }) {
                    self.users[index].scores = scores
                }
            }
        }
        userListeners.append(listener)
    }
    
    private func removeUserListeners() {
        for listener in userListeners {
            listener.remove()
        }
        userListeners.removeAll()
    }
    
    deinit {
        mainListener?.remove()
        removeUserListeners()
    }
}
```