🥇

Firestoreでユニーク制約を実装する

2022/01/15に公開3

Firestoreでは、コレクション内でフィールドを一意(ユニーク)にする簡単な方法はありません。ただ、不可能ではなく、ちょっと面倒ですが方法はあります。少し難しいので解説しながら実装します。

基本設計

ユニークなフィールドを作成するのには、実データを保管するコレクションの他に、indexコレクションを作成します。indexコレクションに、ユニークにしたいフィールドをキーとして登録します。
例えば、ユーザIDの下にメールアドレスが登録されているusersコレクションを考えます。このとき、メールアドレスを一意にしたいときは、ユーザの情報を登録すると同時に、indexコレクションにもメールアドレスをキー、ユーザIDをデータとして登録します。

トランザクションとバッチ書き込み

Firestoreにおけるトランザクションとバッチ書き込みは、複数のドキュメントを同時に書き込むことです。書き込む前に情報の取得(get)を行い、そのデータに対するロックが必要な場合は、トランザクション、書き込みのみを行う場合はバッチ書き込みを使用します。

ユニーク制約を実装する

ユニーク制約を実装していきましょう。

Create

はじめにcreateです。Createでは、実データとindexデータを同時に書き込みます。

ソースコード(重要な部分のみ抜粋)

import { doc, runTransaction, writeBatch } from "firebase/firestore"

export const createUser = async (uid: string, email: string) => {
  const batch = writeBatch(db)
  
  // user情報を登録
  const docRef = doc(db, "users", uid)
  batch.set(docRef, {
    email,
  })

  // index情報を登録
  const indexRef = doc(db, "index", "users", "email", email)
  batch.set(indexRef, {
    user: uid,
  })

  await batch.commit()
}

batchを使用して、user情報とindex情報を同時に書き込んでいます。こうすることで、セキュリティルールによってユニークを実装することができます。また、どちらかの情報のみが書き込まれるのを防ぎます。

次にセキュリティルールです。セキュリティルールは、実データ側、インデックス側両方に記述することが必要です。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{user} {
      allow create: if 
      	getAfter(
          /databases/$(database)/documents/index/users/email/$(request.resource.data.email)
        ).data.user == user;
    }
    
    match /index/users/email/{email} {
      allow create: if 
      	getAfter(
          /databases/$(database)/documents/users/$(request.resource.data.user)
        ).data.email == email;
    }
  }
}

getAfterでバッチ処理で書き込まれる値を取得することができます。実データ側では、書き込もうとしているユーザID、emailが、indexのemail、ユーザIDと一致するかをチェックします。インデックス側では反対に、書き込もうとしているemail、ユーザIDが、実データ側のemail、ユーザIDと一致するかチェックします。

Delete

次に、deleteです。Deleteでは、実データとインデックスのデータを同時に消去します。

ソースコード(抜粋)です。データを消去する前に現在のデータを取得します。実行するまでにそのデータが変更されないことを保証しなければいけないので、トランザクションを使用します。

export const deleteUser = async (uid: string) => {
  try {
    await runTransaction(db, async (transaction) => {
      // 更新前のuser情報を取得
      const docRef = doc(db, "users", uid)
      const docSnap = await transaction.get(docRef)

      if (!docSnap.exists()) {
        throw "Document does not exist!"
      }

      // user情報を削除
      transaction.delete(docRef)

      // インデックスを削除
      const prevIndexRef = doc(db, "index", "users", "email", docSnap.data()["email"])
      transaction.delete(prevIndexRef)
    })
  } catch (e) {
    console.log("Transaction failed: ", e);
  }
}

セキュリティルールです。Deleteでも、セキュリティルールは、実データ側、インデックス側両方に記述することが必要です。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{user} {
      allow read: if true;
      
      // create は上記と同じ
      
      allow delete: if
      	!existsAfter(
          /databases/$(database)/documents/index/users/email/$(resource.data.email)
        );
    }
    
    match /index/users/email/{email} {
      // create は上記と同じ
        
      allow delete: if
      	!existsAfter(
          /databases/$(database)/documents/users/$(resource.data.user)
        );
    }
  }
}

existsAfterを使用して、トランザクション終了時にデータが存在しないことを保証しています。

Update

最後にupdateです。Updateでは、実データを更新し、古い情報のインデックスを削除し、新しい情報のインデックスを作成します。

ソースコード(抜粋)です。Updateも、deleteと同様に更新前の値が必要なのでトランザクションを使用します。

export const updateUser = async (uid: string, email: string) => {
  try {
    await runTransaction(db, async (transaction) => {
      // 更新前のデータを取得
      const docRef = doc(db, "users", uid)
      const docSnap = await transaction.get(docRef)

      if (!docSnap.exists()) {
        throw "Document does not exist!"
      }

      // user情報を更新
      transaction.update(docRef, {
        email,
      })

      // 更新前のインデックスを削除
      const prevIndexRef = doc(db, "index", "users", "email", docSnap.data()["email"])
      transaction.delete(prevIndexRef)

      // 更新後のインデックスを作成
      const indexRef = doc(db, "index", "users", "email", email)
      transaction.set(indexRef, {
        user: uid,
      })
    })
  } catch (e) {
    console.log("Transaction failed: ", e);
  }
}

セキュリティルールです。実データ側では、更新前のデータのインデックスが存在しないこと、更新後のインデックスが存在することを確認します。インデックス側のdeleteは、メールアドレスが一致しないことを追記します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{user} {
      allow read: if true;
      
      // create は上記と同じ
        
      allow update: if
      	getAfter(
          /databases/$(database)/documents/index/users/email/$(request.resource.data.email)
        ).data.user == user &&
	!existsAfter(
          /databases/$(database)/documents/index/users/email/$(resource.data.email)
        );
      
      // delete は上記と同じ
    
    match /index/users/email/{email} {
      // create は上記と同じ
        
      allow delete: if
      	!existsAfter(
          /databases/$(database)/documents/users/$(resource.data.user)
        ) ||
        getAfter(
          /databases/$(database)/documents/users/$(resource.data.user)
	).data.email != email;
    }
  }
}

ユニーク性の検証

以上でコレクション内で値を一意にする実装は終了です。このような実装とセキュリティの実装ルールを行うと、例えば、別ユーザが同じメールアドレスを使用しようとしたとき、実データとインデックスの内容を更新しようとしますがインデックスがupdateが許可されていないので更新は失敗します。

参考

https://stackoverflow.com/questions/47543251/firestore-unique-index-or-unique-constraint

Discussion

Sodbileg GansukhSodbileg Gansukh

That's really great, thanks for the article! I've implemented it on my project and it works great.

I only have one noob question. Are these rules supposed to apply when I update the documents manually in the console?

yucatioyucatio

Hi, Thank you for your comment.

Are these rules supposed to apply when I update the documents manually in the console?

No, manual update bypass the security rule.