Firestoreを使うときの備忘録


#前書き
ちまちまFirestoreを触る中で忘れたくない小技をまとめたもの。具体例はFirebaseのクライアントSDK(v8)で記述していますが、adminSDKやGoogle cloudのSDKでも使えるはずです。

# ドキュメントに含めておいた方がよい情報
- ドキュメントを作成したユーザーのUID
- 作成時刻、更新時刻
- 親ドキュメントのID(サブコレクションの場合)

この辺の情報があるだけで後々のクエリが非常に楽になる。
あと認証機能と組み合わせる場合、ユーザーと紐づいているドキュメントはIDをUIDにすると検索が楽。

# Firestoreの小技
## 複数の条件に合致するドキュメントを取得したい
例: 掲示板の投稿一覧から複数のユーザーの投稿を取り出すときなど

inクエリ演算子を使う。

```javascript:ebi.js
// こんな感じのコレクションから投稿者のIDが1と2の投稿を取得したい場合
post={
  aaa:{
    createdBy:'1',
    title:'hogehoge',
    text:'ebikaniuni'
  },
  bbb:{
    createdBy:'2',
    title:'hugahuga',
    text:'ebikaniuni'
  },
  ccc:{
    createdBy:'4',
    title:'piyopiyo',
    text:'ebikaniuni'
  },
}

firebase.firestore().collection('post').where('createdBy','in',['1','2']).get().then(snapshot=>{
  snapshot.forEach(doc=>{
    console.log(doc.data())
  })
})

// 結果
// {
//   createdBy:'1',
//   title:'hogehoge',
//   text:'ebikaniuni'
// }
// {
//   createdBy:'2',
//   title:'hugahuga',
//   text:'ebikaniuni'
// }
```
配列の要素は10個までなので注意。

## 範囲指定したい
例: 今月中に投稿された投稿を取得したい時など
一つのフィールドに対してなら範囲指定できる。複数のフィールドに対して範囲指定はできない。

```javascript:kani.js
// ここから200円以上400円以下のドキュメントを取り出す
sushi={
  aaa:{
    name:'えび',
    price:100
  },
  bbb:{
    name:'かに',
    price:200
  },
  ccc:{
    name:'うに',
    price:400
  },
  ddd:{
    name:'トロ',
    price:500
  }
}
firebase.firestore().collection('sushi').where('price','>=',200).where('price','<=',400).get().then(snapshot=>{
  snapshot.forEach(doc=>{
    console.log(doc.data())
  })
})

// 結果
// {
//   name:'かに',
//   price:200
// }
// {
//   name:'うに',
//   price:400
// }
```

## カウンタ
例: 投稿に対するコメント数を集計する場合など
firebase.firestore.FieldValue.increment()を使う。
firestoreに()を付けてはいけない。カウンタは1秒間に1回という制限があるため、頻繁に発生する場合は入力先を分散させるなどの対策が必要。

```javascript:uni.js
post={
  aaa:{
    title:'title',
    text:'text',
    commentNumber:0
  }
}
firebase.firestore()
  .collection('post').doc('aaa')
  .update({
    commentNumber:firebase.firestore.FieldValue.increment(1)
 })
```
## サーバータイムスタンプ
new Date()でも一応現在時刻が入るのだが、あくまでもサーバー側の時間を入れたいときはこちら。
firestoreに()を付けてはいけない。(大事なことなのでもう一度ry)

```javascript:same.js

firebase.firestore()
  .collection('post').doc('aaa')
  .update({
    updatedDate:firebase.firestore.FieldValue.serverTimestamp(1)
 })
```



## 絞り込み対象のフィールドが無いドキュメントはクエリに出ない
例: ログインした事があるユーザーだけを抽出したい場合
orderByで並び替える際、基準となるフィールドが存在しないドキュメントは結果に含まれない。

```javascript:same.js
// ここからトークンを持つユーザーだけを絞り込みたい。
user={
  aaa:{
    name:'hoge',
    token:'aaaaaaaaaa'
  },
  bbb:{
    name:'huga',
    token:'bbbbbbbbbb'
  },
  ccc:{
    name:'hoge',
  }
}

firebase.firestore()
  .collection('user')
  .orderBy('token','desc').get()
    .then(snapshot=>{
      snapshot.forEach(doc=>{
       console.log(doc.data())
    })
})

// 結果
// {
//   name:'hoge',
//   token:'aaaaaaaaaa'
// }
// {
//   name:'huga',
//   token:'bbbbbbbbbb'
// }
```

## フィールドを削除したい。

例: 上の小技を使いたいとき
firebase.firestore.FieldValue.delete()を使う。

```javascript:opabinia.js
// hogeさんからトークンを削除する。
user={
  aaa:{
    name:'hoge',
    token:'aaaaaaaaaa'
  },
  bbb:{
    name:'huga',
    token:'bbbbbbbbbb'
  },
  ccc:{
    name:'hoge',
  }
}
firebase.firestore()
  .collection('user')
  .doc('aaa')
  .update({token:firebase.firestore.FieldValue.delete()})
```

# セキュリティルール
## setメソッドで上書きさせないようにする。
例: フロントエンド側でIDを指定するが、IDが重複した場合に上書きさせたくない。
idを指定してドキュメントを作成するsetメソッドは、すでにドキュメントが存在する場合でも問答無用で作成する。
すでに作成されている場合には上書きしないようにしたい。
※そもそもIDをユーザーが指定できる時点でよろしくないが、IDが衝突しても上書きを防げるため使いどころは一応ある。

```javascript:hallucigenia.js
// ドキュメント作成時に必ず作成日時のフィールドを用意する。 
const payload ={
   // なんかそれっぽいフィールド
    createdDate:firabese.firestore.FieldValue.serverTimestamp()
  } 
```

この時の作成時刻はサーバータイムスタンプにする方が無難。
この作成日時が必ず同じになるようにセキュリティルールを設定する。

```javascript:firestore.rules
match /document/{id} {
      function checkDocument(){ 
        // なんかそれっぽいバリデーション
      }
      function isSameCreatedDate(){
        // ドキュメントが存在しない場合は resouce.dataがnullになる。
        return resource.data ==null || request.resource.data.createdDate == resource.data.createdDate;
      }
      allow create: if checkDocument() ;
      allow update: if isSameCreatedDate();


```
これで上書きを防げる。

### setメソッドに対するセキュリティルール設定時の注意
setメソッドは指定したIDにドキュメントがある場合はupdate、無い場合はcreate扱いになるので注意。

## 配列の扱い
例 グループのメンバーを配列で管理している時
配列で閲覧可能なメンバーを管理しているとき、主催者や自分自身を削除できないようにする。

```javascript:same.js
// こんな感じのグループを管理している
group=[{
    title:'ebi',
    createdBy:'1',
    members:['1','2','3','4']
  }]  
```

UID:'1'のユーザーや管理権限を持つユーザーがメンバー情報を更新する時、自分や作成者を消せないようにする。
フィールドが配列であることを判定するには'is'演算子、配列のフィールドに要素が含まれるかどうかを判定するには"in"演算子を使う。

```javascript:firestore.rules
// is演算子
// 配列ならtrueが返る
newValue is list

// in演算子
// 含まれているとtrueが返る
newValue in field

```
実際にセキュリティルールに使った場合はこんな感じ。

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  // ユーザーデータが実在するか確認する
  function isUserExists(){
    return request.auth.uid != null
    && exists(/databases/$(database)/documents/users/$(request.auth.uid))
  }
  match /databases/{database}/documents {
    match /account/{groupId} {
      function updateGroup(){
        return request.resource.data.size() == 3
        && request.resource.data.title is string // titleは文字列
        && request.resource.data.createdBy is string // 作成者のUIDは文字列である事
        && request.resource.data.createdBy == resouce.data.createdBy // 作成者が変更されていない事
        && request.resource.data.members is list //membarsは配列である事
        && request.auth.uid in resource.data.members // メンバーの配列に実行者のUIDが含まれる事(更新前)
        && request.auth.uid in request.resource.data.members // メンバーの配列に実行者のUIDが含まれる事(更新後)
        && request.resource.data.createdBy in request.resource.data.members // メンバーの一覧に作成者が含まれる事
      }
      // 他の処理は省略
      allow update: if isUserExists() && updateGroup();
    }
  }
}
```

## カスタムクレームを使わずにユーザーの権限を制御する
getメソッドを使うとfirestore中のデータにアクセスできる。

```javascript:sake.js
// こんな感じでユーザーを管理している
user={
  aaaa:{
    name:'ebi',
    admin:true,
    },
  bbbb:{
    name:'kani',
    admin:false,
    },
  } 
```

管理者のみドキュメントを更新させたいときのセキュリティルールはこんな感じ

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  // ユーザーが管理者かどうか確認する
  function isAdmin(){
    return request.auth.uid != null
    && exists(/databases/$(database)/documents/users/$(request.auth.uid))
    // ドキュメント中のadminフィールドを参照する。
    && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.admin
  }
  match /databases/{database}/documents {
    match /account/{groupId} {
      function updateGroup(){
　　　　// 省略
      }
      // 他の処理は省略
      allow update: if isAdmin() && updateGroup();
    }
  }
}
```
### バッチ処理時の注意事項
existsメソッドとgetメソッドには実行回数に制限がある。

> 単一ドキュメントに対するリクエストとクエリ リクエストの場合は 10。
>複数のドキュメントに対する読み取り、トランザクション、バッチ書き込みの場合は 20。各オペレーションには、上記の制限の 10 も適用されます。

>たとえば、3 つの書き込みオペレーションを含めたバッチ書き込みリクエストを作成するとします。セキュリティ ルールでは、2 つのドキュメントに対するアクセス呼び出しを使用して、それぞれの書き込みを検証します。この場合、各書き込みオペレーションがアクセス呼び出し制限数 10 のうちの 2 つを使用するため、バッチ書き込みリクエストはアクセス呼び出し制限数 20 のうちの 6 つを使用することになります。
>[Cloud Firestore セキュリティ ルールの条件の記述](https://firebase.google.com/docs/firestore/security/rules-conditions?authuser=0#data_validation)

なのでセキュリティルール中でgetやexistsを使いすぎるとエラーになる。エラーになるとオペレーションは実行されない。
大量のデータを書き込むバッチ処理の場合、バッチ全体で20回までなので注意

## 名前を重複させたくない
偉い人類「名前を重複できないようにしたいんだけど」
蝦「はい」

```javascript:unique.js
// こんな感じで固有の名前を持つコレクションを管理している
animal={
  aaaa:{
    name:'ebi',
    admin:true,
    },
  bbbb:{
    name:'kani',
    admin:false,
    },
  } 
// ID:aaaaと被るので拒否される
const dame ={
    name:'ebi',
    admin:false,
    }

// 名前が重複しないのでOK
const iiyo = {
    name:'uni',
    admin:false,
    }
```

ここにドキュメントを追加するとき、名前が同じ場合は追加できないようにする。
(空白とか全角半角とかは考えてはいけない。)

### 方法
名前だけを入れた配列をcloud functionsで別途用意し、create時のルールで配列に名前が含まれているか確認する。

```javascript:list.js
// animalコレクションの名前だけを配列に入れたドキュメントを用意
SYSTEM={
    aaaaa:{
        unique:["ebi","kani"]
        }
    }
```

セキュリティルール側で配列内に作成したいコレクションの名前があるか確認する。

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  // 配列内に含まれるかどうか確認する
  function isUnique(){
    return !(request.resouce.data.name in get(/databases/$(database)/documents/SYSTEM/aaaaa).data.unique);
  }
  match /databases/{database}/documents {
    match /account/{groupId} {
      function newAnimal(){
　　　　// 省略
      }
      // 他の処理は省略
      allow update: if newAnimal() && isUnique();
    }
  }
}
```
頻繁に更新されるコレクションでこの方法を使う場合、定期的に配列とコレクションの情報の整合性を確認する必要がある点に注意。

## フィールドを変更禁止・変更可能にしたい
ドキュメントの更新時に作成日時や作成者のID等のようなフィールドを更新できないようにする方法。一番簡単なのは更新前後の値を比較させる方法だが、数が多いと行数が増えてしまう。
そこで使うのが[公式ドキュメントでも紹介されている方法](https://firebase.google.com/docs/firestore/security/rules-fields?hl=ja#restricting_fields_on_update)。更新処理時に変更のあったフィールドのキーの一覧を取得して、その中を確認するという方法。

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /tasks/{taskId} {
      function checkCreate(){
        let newdata = request.resource.data;
        return newData.size() == 7
        && newData.name is string
        && newData.memo is string
        && newData.status in bool
        && newData.uid is string
        && newData.createdBy == request.auth.uid
        && newData.createdAt is timestamp
        && newData.updatedAt is timestamp
    }
      function checkUpdate(){
        // 更新後のデータ
        let after = request.resource.data;
        // 更新前のデータ
        let before = resource.data;
        // 変更されるフィールドのキー一覧
        let updatedFields = after.diff(before).affectedKeys();
        // 変更可能な項目
        return  updatedFields.hasAny(["memo","status"])
        // 必須の項目
        && "updatedAt" in updatedFields
        && after.updatedAt is timestamp
        && after.updatedAt > before.updatedAt
        // 変更禁止の項目
        && !updatedFields.hasAny(["uid","name","createdBy","createdAt"])
      }
      // 他の処理は省略
      allow update: if checkCreate();
      allow update: if checkUpdate();
    }
  }
}
```
こうすればフィールドの数が増えても概ね1行で管理できる。
### セキュリティルールの配列(list,set)が絡む記述のまとめ
フィールドの更新の制御に使うだけならhasAny()とhasAll()の2種類で大体書ける。

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /tasks/{taskId} {
      function checkUpdate(){
        // 更新後のデータ
        let after = request.resource.data;
        // 更新前のデータ
        let before = resource.data;
        // 変更されたフィールドのキーが入った配列
        let updatedFields = after.diff(before).affectedKeys();
        
        // ここから記述の方法
        // 引数にとった配列の要素が全て含まれるかどうか
        && updatedFields.hasAll(["hoge","huga","piyo"])
        // 引数に取った配列内の要素が一つでも含まれるかどうか
        // 変更可能なフィールドの指定に使ったり、
        // 反転させて変更禁止となるフィールドの指定に使ったりする。
        && updatedFields.hasAny(["hoge","huga"])
        // 引数に取った配列内の要素のみで構成されているかどうか
        && updatedFields.hasOnly(["hoge","huga"])
      }
      // 他の処理は省略
      allow update: if checkUpdate();
    }
  }
}
```