【Next.js + TypeScript】Cloud Functionsを使ってFirestoreのカスケーディングデリート機能を実装する

この記事では **「Firestoreでドキュメントが削除されたときに、その削除されたドキュメントのリファレンスをフィールドに持っている他のコレクションのドキュメントも自動で削除してくれる機能」** をCloud Functionsを使ってどのように実装していくのかを解説していきます。

## 使用技術

* Next.js 13.4.10

* Firebase 10.0.0

* Chakra UI 2.8.0

* React Hook Form 7.45.2

デザインに関してはChakra UIを使って整えていきます。

## 必要なライブラリのインストール

Firebase
```
 yarn add firebase @types/firebase
```

Chakra UI
```
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
```

React Hook Form
```
yarn add react-hook-form
```


## Firebaseの設定と初期化

まずは、Firebaseの設定と初期化から行なっていきましょう。Firebaseの管理画面から新しいプロジェクトを作成し、Firebaseの初期化を行います。

:::note warn
Cloud FunctionsはFirebaseが有料プランになっていないと使うことができません。
:::

今回はNext.jsのプロジェクトのルートディレクトリにfirebaseというディレクトリを作成し、その中にfirebase.tsというファイルを作り、ここでFirebaseの初期化を行います。

Firebaseの初期化や設定について詳しく知りたい方はこちらの記事を見てください。

https://qiita.com/masakiwakabayashi/items/741998ed5b830d8f3707

## Firestoreのデータ構造

今回の実装では、

* subjects：科目の情報が保存されるコレクション

* teachers：講師の情報が保存されるコレクション

という2つのコレクションがあり、teachersのドキュメントが担当科目のフィールド(subjectInCharge)で科目のリファレンスを持つという想定です。

つまり、もし科目(subjectsコレクション)のドキュメントが削除された場合に、その科目のリファレンスをsubjectInChargeのフィールドに持っている講師(teachersコレクション)のドキュメントのsubjectInChargeフィールドが削除されるようにするということですね。

teachersのドキュメントは後ほどNext.jsの画面から作成できるようにしていくので、ここでは一旦、subjectsコレクションのドキュメントをいくつか作成します。

以下のようなIDとnameフィールドを持つドキュメントをsubjectsコレクションに作成してください。

|  ドキュメントID  |  name  |
| ---- | ---- |
|  456789  |  数学  |
|  123456  |  英語  |
|  908172  |  現代文  |
|  789123  |  世界史  |
|  654321  |  日本史  |

実際にFirestoreにデータを作成すると、以下のような感じになります。
![スクリーンショット 2023-07-20 16.27.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/cda64a96-8336-489e-b7fd-c34d05b8c4c5.png)

データを作成したら、次は講師のドキュメントを作成する処理と科目を削除する処理をつくっていきます。

## ドキュメントを削除する処理とドキュメントのリファレンスを持つデータを作成する処理を実装

まずは、科目のドキュメントを画面上に一覧表示する機能と、その画面から指定のドキュメントを削除できる機能を作成していきます。

Next.jsのpagesディレクトリにsubjects.tsxというファイルを作成し、中身を以下のように書きます。

```pages/subjects.tsx
import React, { useState, useEffect } from "react";
import { db } from "../firebase/firebase";
import {
    Box,
    Button,
    Flex,
} from "@chakra-ui/react";
import { getDocs, collection, doc, deleteDoc } from 'firebase/firestore';


const Subjects = () => {
    // 科目のデータを格納するためのuseState
    const [ subjects, setSubjects ] = useState([]);

    // Firestoreから科目のデータを取得する処理
    const getData = async () => {
        const querySnapshot = await getDocs(collection(db, 'subjects'));
        const subjectsArray: any = [];
        querySnapshot.docs.map((doc)=>{
            subjectsArray.push({
                id: doc.id,
                name: doc.data().name,
            });
        });
        setSubjects(subjectsArray);
    }

    // 科目データを削除する処理
    const deleteData = async (id: string) => {
        await deleteDoc(doc(db, 'subjects', id));
    }

    // useEffectでgetDataを実行
    useEffect(()=>{
        getData();
    },[]);

    return (
        <Box>
            <Box>
                <Flex>
                    <Box p={3} fontWeight={'bold'}>
                        科目名
                    </Box>
                    <Box>
                    </Box>
                </Flex>
            </Box>
            {
                // map関数を使ってstaffのデータを表示
                subjects.map((item: any)=>{
                    return (
                        <Box key={item.id}>
                            <Flex>
                                <Box p={3} minWidth={'73px'}>
                                    {item.name}
                                </Box>
                                <Box>
                                    <Button onClick={()=>{
                                        deleteData(item.id);
                                        getData();
                                    }}>削除</Button>
                                </Box>
                            </Flex>
                        </Box>
                    )
                })
            }
        </Box>
    );
}

export default Subjects;
```
これでFirestoreの科目データの一覧表示と削除の機能ができました。実際にsubjectの画面を開くと以下のようになっています。
![スクリーンショット 2023-07-20 11.07.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/4e3da22e-0f20-0f7a-de05-5905fdbe32c5.png)

次は講師のデータを作成できるようにしていきます。

科目の時と同様にpagesディレクトリにteachers.tsxというファイルを作成し、中身を以下のようにしておきます。

```pages/teachers.tsx
import React, { useState, useEffect } from "react";
import { db } from "../firebase/firebase";
import {
    Box,
    Button,
    Flex,
    FormLabel,
    FormControl,
    Input,
    Select,
} from "@chakra-ui/react";
import { getDocs, collection, doc, addDoc } from 'firebase/firestore';
import { useForm } from 'react-hook-form'

// フォームで使用する変数の型を定義
type formInputs = {
    lastName: string;
    firstName: string;
    subjectInCharge: any;
}

const Teachers = () => {
    // 講師の担当科目のセレクトボックスに表示させる科目データを入れるためのuseState
    const [ subjects, setSubjects ] = useState([]);

    // React Hook Form
    const {
        handleSubmit,
        register,
        formState: { errors, isSubmitting },
    } = useForm<formInputs>()

    // 科目データを取得する処理
    const getData = async () => {
        const querySnapshot = await getDocs(collection(db, 'subjects'));
        const subjectsArray: any = [];
        querySnapshot.docs.map((doc)=>{
            subjectsArray.push({
                id: doc.id,
                name: doc.data().name,
            });
        });
        setSubjects(subjectsArray);
    }

    // useEffectでgetDataを実行
    useEffect(()=>{
        getData();
    },[]);


    // フォームが送信されたときの処理
    const onSubmit = handleSubmit( async (data) => {
        // フォームで送信された科目のIDをリファレンスに変換する処理
        const subjectRef = doc(db, 'subjects', data.subjectInCharge);
        // Firestoreにデータを追加する処理
        await addDoc(collection(db, 'teachers'), {
            lastName: data.lastName,
            firstName: data.firstName,
            subjectInCharge: subjectRef,
        }).then(()=>{
            console.log('Document successfully written!');
        });
    });


    return (
        <Box m={4}>
            <form onSubmit={onSubmit}>
                {/* 講師の姓 */}
                <FormControl mb={5}>
                    <FormLabel htmlFor='lastName'>講師の姓</FormLabel>
                    <Input
                        id='lastName'
                        {...register('lastName')}
                    />
                </FormControl>
                {/* 講師の名 */}
                <FormControl mb={5}>
                    <FormLabel htmlFor='firstName'>講師の名</FormLabel>
                    <Input
                        id='firstName'
                        {...register('firstName')}
                    />
                </FormControl>
                {/* 担当科目 */}
                <FormControl mb={5}>
                    <FormLabel htmlFor='subjectInCharge'>担当科目</FormLabel>
                    <Select id='subjectInCharge' placeholder='担当科目' {...register('subjectInCharge')}>
                        {
                            subjects.map((item: any)=>{
                                return (
                                    <option key={item.id} value={item.id}>{item.name}</option>
                                )
                            })
                        }
                    </Select>
                </FormControl>
                <Button mt={4} colorScheme='blue' isLoading={isSubmitting} type='submit'>
                    送信
                </Button>
            </form>
        </Box>
    );
}

export default Teachers;
```

これで以下のようなフォームができます。
![スクリーンショット 2023-07-20 13.12.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/e207fc11-7cf1-c8de-7715-1d5bfd3c6e82.png)

上記の講師情報を登録するためのフォームはReact Hook Formを使って作成しています。React Hook Formの詳しい使い方については、こちらの記事で解説しています。

https://qiita.com/masakiwakabayashi/items/01255c633c8786537606

上記のフォームを使って講師の情報を登録すると、Firestoreに講師のドキュメントが保存されていて、担当科目のsubjectInChargeはFirestoreの科目ドキュメントのリファレンスになっていることが確認できると思います。
![スクリーンショット 2023-07-20 13.09.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/62f6ce07-5086-9feb-4e62-5dbe283de0bf.png)

## Cloud Functionsの関数を作成する

ここからはCloud Functionsの関数を作成していきます。まずはカスケーディングデリートの関数ではなく、簡単なものを作成してCloud Functionsの関数をFirebaseにデプロイするまでの手順を解説します。

Cloud Functionsの関数を作成する前に、ターミナルでfirebaseコマンドを使えるようにするために以下のコマンドを実行します。

```
npm install -g firebase-tools
```

次はcloud_functionsというフォルダを新しく作成し、このディレクトリに移動して、ターミナルで以下のコマンドを実行してください。

```
firebase init
```

すると、ターミナルが以下のようになるので、必要な機能をスペースで選択します(今回はFirestoreとFunctionsを選択します)。
![スクリーンショット 2023-07-20 13.26.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/db217e2a-8cf2-2c5e-ad0e-b007d34263df.png)

次は、既存のプロジェクトを使うか新しいプロジェクトといったことを聞かれます。今回は使うプロジェクトをすでに作成しているので、ここではUse an existing projectを選択します。
![スクリーンショット 2023-07-20 13.28.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/990c579c-66d8-2188-c557-8cce44a33581.png)

既存のプロジェクトが表示されるので、今回使うプロジェクトを選択します。
![スクリーンショット 2023-07-20 13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/ef4d2004-d0e9-e78c-4594-8aed38f886d9.png)
※上記の図ではプロジェクトは1種類だけ表示されています。

次は、

「What file should be used for Firestore Rules?」

「What file should be used for Firestore indexes?」

という質問がきますが、こちらは2つとも何も入力せずにエンターを押してください。

これらの質問に回答すると、

「What language would you like to use to write Cloud Functions?」
(Cloud Functionsを書くのに、どの言語を使うか？)

という質問がきます。JavaScriptを使うかTypeScriptを使うかを選べるので、今回はTypeScriptを選択して進めます。
![スクリーンショット 2023-07-20 13.32.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/70fea300-711b-aee9-8ee4-2bd6f873e8fc.png)

ESLintを使うかどうかという質問はNoと回答してください(Yesと回答してしまった場合でも後ほど無効化できます)。ESLintを有効にするとこれから実装するカスケーディングデリートの関数がデプロイできない場合があります。

この次に来る

「Do you want to install dependencies with npm now?」
(依存性を今すぐにインストールするか？)

という質問に関してはYesと答えてください。
![スクリーンショット 2023-07-20 13.37.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/3901d5f9-c0ef-c681-19ce-dbbe49b126ac.png)

回答が完了するとターミナルが以下のようになって「Firebase initialization complete!」と表示されます。

これでCloud Functionsを使うための準備は完了です。
![スクリーンショット 2023-07-20 13.40.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/7dc53e78-9c11-7def-a859-f12146eb6926.png)

## Cloud Functionsの関数を作成してデプロイする

では、実際にCloud Functionsの関数を作成していきます。先ほど作成したcloud_functionsのディレクトリをVSコードで開きます。
![スクリーンショット 2023-07-20 14.09.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/b55d2069-8f89-8117-f743-a487e503e139.png)

functionsの中のsrcというディレクトリにindex.tsというファイルがあるので、このファイルを開きます。すると、以下のようにhelloWorldという関数が作成されており、コメントアウトされていると思います。
![スクリーンショット 2023-07-20 14.11.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/038e2768-6ece-4c4f-16a5-116c5488ff8c.png)

このコメントアウトを解除します。以下が実際のindex.tsの中身です。

```functions/src/index.ts
import * as functions from "firebase-functions";

// Start writing functions
// https://firebase.google.com/docs/functions/typescript

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!");
});
```
Cloud Functionsの関数はこのindex.tsのファイルに作成していきます。カスケーディングデリートの関数を作成する前に、この関数を実際にデプロイしてみましょう。

ターミナルで以下のコマンドを入力し、Firebaseにログインします。

```
firebase login
```

ログインしたら以下のコマンドを実行して、Cloud Functionsの関数(helloWorld関数)をFirebaseにデプロイします。

```
firebase deploy
```

Cloud Functionsのデプロイはやや時間がかかるので、少し待ちます。

以下のようにターミナルに「Deploy complete!」の表示が出たことが確認できたらデプロイは完了です。
![スクリーンショット 2023-07-20 14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/ef13c4cb-a869-0ab9-d23a-c15d7e927a35.png)

デプロイが完了したら、Firebaseの管理画面からhelloWorld関数がデプロイされているかどうかを確認してみましょう。

Firebaseの管理画面を開き、サイドバーの「構築」の中にある「Functions」を開いてみてください。関数の一覧のところにhelloWorldがあるはずです。
![スクリーンショット 2023-07-20 14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/96f7f86d-e772-3a94-bd57-1eb7ec0d0b54.png)

※私の場合helloWorld以外にも様々な関数をデプロイしているため多くの関数が一覧に表示されていますが、みなさんは現時点ではhelloWorldしかデプロイしていないため、helloWorldが関数の一覧に表示されていれば問題ありません。

## カスケーディングデリートを行うCloud Functionsの関数を作成する

ここまででCloud Functionsの関数を作成する方法とデプロイする手順を解説したので、ここからはFirestoreのデータに対してカスケーディングデリートを行う関数を作成していきます。

まずは、先ほどhelloWorldのコメントアウトを解除したindex.tsに以下のような関数を定義しておきます。また、firebase-adminを使うための記述も以下のように追記してください。

```functions/src/index.ts
import * as functions from "firebase-functions";
// firebase-adminをインポート
import * as admin from 'firebase-admin';
// firebase-adminの初期化
admin.initializeApp();

// Start writing functions
// https://firebase.google.com/docs/functions/typescript

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", {structuredData: true});
  response.send("Hello from Firebase!");
});


// 科目が削除されたときに講師の担当科目を削除する処理
export const onSubjectDelete = functions.firestore
  .document('subjects/{subjectId}')
  .onDelete(async (snap, context) => {
    // ここにカスケーディングデリートの処理を書いていく

  });
```

この関数は指定のコレクションにあるドキュメントが削除された場合に自動で実行されるようになっているものです。

Cloud Functionsには

|  トリガー  |  実行されるタイミング  |
| ---- | ---- |
|  onDocumentCreated  |  ドキュメントが作成されたときに  |
|  onDocumentUpdated  |  すでに存在するドキュメントの値が変更されたとき  |
|  onDocumentDeleted  |  ドキュメントが削除されたとき  |
|  onDocumentWritten  |  onDocumentCreated、onDocumentUpdated または onDocumentDeleted がトリガーされたとき  |

といったトリガーがあり、色々なタイミングでデプロイした関数を実行することができます。

今回はドキュメントが削除されたときに実行される関数をつくりたいので、onDeleteを使います。

では、このonSubjectDelete関数の中身を書いていきます。onSubjectDelete関数を以下のように変更してください。

```functions/src/index.ts
// 科目が削除されたときに講師の担当科目を削除する処理
export const onSubjectDelete = functions.firestore
  .document('subjects/{subjectId}')
  .onDelete(async (snap, context) => {
    // 削除された科目のドキュメントIDを取得
    const deletedSubjectId = context.params.subjectId;
    // 講師のドキュメントを全て取得
    const db = admin.firestore();
    const teachers = db.collection(`teachers`);
    const teachersQuerySnapshot = await teachers.get();
    // 全ての講師のデータに対して繰り返しを行う
    const updates = teachersQuerySnapshot.docs.map(async (doc) => {
      const docData = doc.data();
      // docData.subjectInChargeが担当科目のリファレンス
      const subjectInCharge = docData.subjectInCharge;
      // 講師の担当科目と削除された科目が一致していた場合、その講師の担当科目を削除する
      if (deletedSubjectId == subjectInCharge.id) {
        // 講師の担当科目を削除する
        return doc.ref.update(
          { subjectInCharge: admin.firestore.FieldValue.delete() }
        );
      } else {
        // 何も更新しない場合でもPromiseを返す
        return Promise.resolve();
      }
    });
    return Promise.all(updates);
  });
```

削除された科目のIDを取得し、講師の全てのドキュメントに対して繰り返しを行い、担当科目として保存されているリファレンスのドキュメントIDが、削除された科目のドキュメントIDと同じかどうかを確認する。

そして、もし講師の担当科目のドキュメントIDと削除された科目のドキュメントIDが同じだった場合は、その講師の担当科目の値を削除する。

といった内容の処理がonSubjectDelete関数の中身です。

実際に、こちらの関数もFirebaseにデプロイして、科目一覧の画面で削除ボタンを押してみると、講師の担当科目が削除された科目と一致していた場合、講師の担当科目のフィールドが消えることが確認できます。

## まとめ

FirebaseのCloud Functionsを使うことによって、Firestoreのカスケーディングデリート機能をつくることができる。

今回の解説に使ったNext.jsのプロジェクトのリポジトリはこちらです。

https://github.com/masakiwakabayashi/next_firestore_cascading_delete

また、今回の解説に使ったCloud Functionsの関数はこちらのリポジトリで公開しています。

https://github.com/masakiwakabayashi/cascading_delete_cloud_functions