🔥

FireStore: update map in array

2024/09/05に公開

FireStore で Array フィールドの中に Map 型(つまり Object)を入れる場合の update 処理をメモ。Map(Object) in Array vs Sub Collection というのも迷ったが、ドキュメントの Array のフィールドの上限が 1MiB らしく、相当な数の Object を突っ込まない限りは、(FireStore は Read したときの対象ドキュメント数によって課金されていくので) Sub Collection よりもいいのではないかと思った。

Model

今回は少し私のプロジェクトが特殊だからかもだが、自分で作る生き物図鑑アプリにおいて、その図鑑の「科(Family)」と「種(Species)」の代表画像(サムネイル)につかう画像を投稿から選択するようなユースケースだった。familyRepPosts, speciesRepPosts フィールドが Map(Object) in Array の型で User ドキュメントに持たせてある。

/users/{userID}
 - id: String
 - name: String
 - familyRepPosts: Array<{
        familyID: String
        postID: String
        imageUrl: String
    }>
- speciesRepPosts: Array<{
        speciesID: String
        postID: String
        imageUrl: String
    }>

最初 Post ドキュメント側に isFamilyRep という Bool 値を持たせていたが、代表画像にできる投稿は1つなので、1つを true にすると、既存の代表画像に設定していた投稿の false にトグルする(固定を外す)処理が必要になり、場合によっては設定変更のたびに全 posts を Read する必要も出てくるかもと思い、User ドキュメントに上記のようなフィールドを持たせることにした。

ただ今気付いたが、結局今回のやり方も更新には最低2回の書き込みが必要なので collection("posts").where(isFamilyRep: true).update({ isFamilyRep: false }) みたいな感じやったほうがシンプルなのでは..。

Swift

import Firebase
import FirebaseFireStore

class PostDetailViewModel {
    let user: User
    let post: Post
    
    func setAsFamilyRep() {
        if let exsistingFamilyRep = user.familyRepPosts?.first(where: { $0.familyID == post.familyID }) {
            // 既存の代表画像があれば arrayRemove する
            let deletingData = [
                "familyRepPosts": FieldValue.arrayRemove([[ // ここが `{}` でなく `[[]]` になっている
                    "familyID": exsistingFamilyRep.familyID,
                    "postID": exsistingFamilyRep.postID,
                    "imageUrl": exsistingFamilyRep.imageUrl,
                    ]])
                ]
            try await Firestore.firestore().collection("users").document(user.uid).updateData(deletingData)
        }
        // 新たに代表画像にする投稿を追加する
        let addingData = [
            "familyRepPosts": FieldValue.arrayUnion([[
                "familyID": familyID,
                "postID": post.id,
                "imageUrl": post.imageUrl,
            ]])
        ]
        try await Firestore.firestore().collection("users").document(user.uid).updateData(addingData)
    }
}

補足として既に代表画像に設定されている投稿を再設定しようとした場合は無駄な書き込みが走るので、フロント側の UI でむしろ「代表画像から解除」のようにメニューを変える必要がある。

また簡単のために ViewModel で FireStore と通信させているが、実際は Service 層や Repository 層に切り分けて運用する。さらにそもそも SwiftUI においては ViewModel を作るアーキテクチャは変だという議論もある。

Cloud Functions (JavaScript)

またもし代表画像に設定している投稿が削除された場合に、Firebase Function で User ドキュメントを更新する書き方はこう。

const functions = require('firebase-functions');
const { initializeApp } = require('firebase-admin/app');
const { getFirestore, FieldValue } = require('firebase-admin/firestore');
const { getStorage } = require('firebase-admin/storage');

const app = initializeApp();
const db = getFirestore(app);
const storage = getStorage(app);
db.settings({ ignoreUndefinedProperties: true });

exports.removeRepPosts = functions.firestore
  .document('/users/{userId}/posts/{postId}')
  .onDelete((snapshot, context) => {
    const userId = context.params.userId;
    const post = snapshot.data();

    let familyRep = {
      familyID: post.familyID,
      postID: post.id,
      imageUrl: post.imageUrl,
    };
    let speciesRep = {
      speciesID: post.speciesID,
      postID: post.id,
      imageUrl: post.imageUrl,
    };
    db.collection('users')
      .doc(userId)
      .update({
        familyRepPosts: FieldValue.arrayRemove(familyRep),
        speciesRepPosts: FieldValue.arrayRemove(speciesRep),
      })
      .catch((e) => console.log(e));
  });

JS においては Object の key 名はダブルクォーテーションで囲む・囲まないはどちらでもいいようだ。

ただこれも含めると最大3回の Write が必要になったので、やはり Post ドキュメント自体に isFamilyRep を持たせる形式の方が良かったのかも..。Firebase Function の方がコストも高そうだし。

その他

Map (Object) の更新はドット表記すると指定の1プロパティのみ更新できるらしいので、これを上手く組み合わせて arrayRemove をしなくて済むようにしたい。
https://firebase.google.com/docs/firestore/manage-data/add-data?hl=ja#update_fields_in_nested_objects

Discussion