Firestoreのドキュメント追加で気をつけたいこと

# 初めに

Firestoreを利用する上で当たり前のように行うドキュメントの追加ですが、ちょっと間違うと大変なことになりえるので書いておきたいと思います。


# ２つの追加方法

公式ページにちゃんと書いてありますが、ドキュメントの追加には２つのやり方が存在しています。

一つ目は、`addDoc`(v8だと`collection("コレクション名").add`)を使う方法です。
関数もその名の通りです。

```ts:v9の場合
import { getFirestore, collection, addDoc } from "firebase/firestore"; 

await addDoc(collection(getFirestore(), "cities"), {name: '東京'});
```
```ts:v8の場合
await db.collection("cities").add({name: '東京'});
```

もう一つは、setDocを使う方法です。
```ts:v9の場合
import { getFirestore, collection, doc, setDoc } from "firebase/firestore"; 

await setDoc(doc(collection(getFirestore(), "cities"))), {name: '東京'});
```
```ts:v8の場合
await db.collection("cities").doc().set({name: '東京'});
```

公式ページではこれは等価であるという説明があり、用途により使い分ければいいという話になっています。
実はここに罠があります。

## setDocはあくまで上書きするもの

`setDoc`なのですが、idとしての引数をつけないことでdocで自動採番して追加する仕組みをとっています。
なので、docが生成するidによっては上書きされることになります！

docで自動生成するものは、被らないんじゃないの？と思ってらっしゃる方がいるかもしれません。
が、実はここでの自動生成は単純にランダムな文字列を作っているだけなので、かなり可能性は低いにせよ同じidが発行される可能性が残ります。

## addDocは上書きを許さない

じゃあ`addDoc`も同じく内部的にidを自動採番してるので同じリスクをもっているんじゃない？と思う方もいらっしゃると思います。
公式ページで同じであると書いてあるくらいですからね。

ですが実際には差がありました。

ちょっと前に書いた以下の記事でそれに気づきました。

https://qiita.com/kontikun/items/d4090fbf80d7fb03b13a

ここの`addDoc`をしたときに`currentDocument.exists`が`false`になっていました。
実際に`firebase-js-sdk`のソースコードを見てみると、確かに`setDoc`はドキュメントがあるかないかのチェックはしておらず、`addDoc`はレコードがないことをチェックしているようでした。

https://github.com/firebase/firebase-js-sdk/blob/cdada6c68f9740d13dd6674bcb658e28e68253b6/packages/firestore/src/api/reference_impl.ts#L396-L417

なので、確実にドキュメントを上書きせず追加をするには`addDoc`を使えばいいということになります！
以上！
としたいところですが、たまーに先にidを発行して追加したいってときがありますよね？

では、どう解決するか？
ここでセキュリティルールの登場です。

# セキュリティルールで追加 or 変更の条件を絞る

いくつかパターンがあると思うので、単純な順から説明していきます。

## 更新をさせない

これが一番シンプルかと思います。
セキュリティルール的には以下の書き方になると思います。

```js
match /cities/{cityId} {
  allow create: if true;
}
```

セキュリティルールはホワイトリストで書いていくので、updateを抜いて書けばいいです。
実際にはこれだと弱すぎるので`true`のところが何らかの条件になると思います。

ただ、これだと更新処理ができなくなるのでなかなか使えるケースは少ないと思います。

## 更新できるケースを制限する

更新と追加の処理が明確に分けられるようにして、それに対してセキュリティルールを書く方法があります。
元々のデータ構造に依存しないようにするために、ここでは追加日時を持たせることで、更新させることをガードする方法を記載します。

まず追加の時、追加した時間を入れるようにします。

```ts:v9の場合
import { getFirestore, collection, addDoc, serverTimestamp } from "firebase/firestore"; 

await addDoc(
  collection(getFirestore(), "cities"),
  { name: '東京', createdAt: serverTimestamp() }
);
```

意図的な更新の時はこの`createdAt`が更新されないことをルールで表現すればいいので、以下の形になるかと思います。

```js
match /cities/{cityId} {
  allow create: if true;
  allow update: if request.resource.data.createdAt == resource.data.createdAt;
}
```

追加のときにわざと`null`を入れたりすると避けられるので、もっと厳しく書くと、以下のように書けます。

```js
match /cities/{cityId} {
  allow create: if request.time == request.resource.data.createdAt;
  allow update: if request.resource.data.createdAt == resource.data.createdAt;
}
```

# firebase-adminを使う場合

`firebase-admin`のSDKを使うとFirestoreのセキュリティルールは効果がないので上の方法は使えません。
使えない代わりに、`firebase-admin`では、`DocumentRefrence`の中に`create`という関数が存在していて、それを使えばドキュメントIDが被ったときにエラーが出るようになります。
Batchにも実装されているので、`Cloud Functions`などで、idを先に作るようなときは`set`ではなく`create`を使うようにすればよさそうです。

# 終わりに

一番単純な追加という操作ですが、意外とハマりどころがあるものですね。
実際にはidが被るような事態は、ずっと大量データを作っていくようなコレクションでなければなかなか起きないと思います。
とはいえ、発生するとめんどうなことになる可能性はあるので、やれることはやっておいた方がいいと思います。

そもそもでいうと`setDoc`にオプションで追加のみの指定を作ってくれればセキュリティルールでわざわざこんなことする必要はないんですが、いつか対応されますかね。

Firebase SDKのGithubのissueに載ってたのでいつか対応されるかもしれませんね。

https://github.com/firebase/firebase-js-sdk/issues/6707