Firestoreでユニーク制約を実装する
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が許可されていないので更新は失敗します。
参考
Discussion
出来ないと思ってcloud function経由させてたんですが、こんな方法があるんですね👏
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?
Hi, Thank you for your comment.
No, manual update bypass the security rule.