🥇

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

6 min read 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

出来ないと思ってcloud function経由させてたんですが、こんな方法があるんですね👏

とても参考になる記事をありがとうございます!!
(Firestoreのデータモデリングは勿論、個人的にはv9でのトランザクションとバッチ書き込みの記法についても解説してあるのが助かりました)

質問させて頂きたいのですが、index コレクション配下に email サブコレクションを作成するのと、トップレベルのコレクションとしてインデックス用に email コレクションを作成するのはどちらが良いと思いますか?
質問させて頂きたいのですが、本記事のように index コレクションを入れ子構造にするのと、トップレベルのコレクションとしてインデックス用に email コレクションを作成するのはどちらが良いと思いますか?

個人的にはトップレベルのコレクションの方が扱いやすいんですが、ユニーク制約を実現したい項目の数だけトップレベルのコレクションがあるのは見苦しい(?)のかなと

具体例として、以前ユーザーネームの重複を避けるために下記のようなFirestoreの構造にしました。

トップレベルのコレクションとして usersusernames の二つを作成

users/uid ※の フィールド

{
username: string,
createdAt: string,
...etc
}
(※docId としてFirebase Authの uid を使用)

usernames/username ※のフィールド

{
uid: string
}
(※ docId として users/uidusername フィールドを使用)

Kyohei さん

コメントありがとうございます。最初に、すいませんが、私はFirebaseを趣味レベルでしか使っていなく、ユニークの実装も1度しかしていないので、あまり知見はありません。

質問させて頂きたいのですが、本記事のように index コレクションを入れ子構造にするのと、トップレベルのコレクションとしてインデックス用に email コレクションを作成するのはどちらが良いと思いますか?

個人的にはトップレベルにindexがあったほうが、用途が想像しやすいので好みです。
(記事の中でトップレベルをindexにしたのは、参考にした記事がそうなっていたからですが。)
ただ、混乱が無さそうであればemail (もしくはemails)をとっぷれべるにしてもよいと思います。

ログインするとコメントできます