Angular+Cloud FirestoreでCRUD(CREATE, READ, UPDATE, DELETE)を実装する

> この記事は[Angular+Firebaseでチャットアプリを作る](http://qiita.com/Yamamoto0525/items/a76ea4b3924eeb82b0f9)のエントリーです。
> 前記事：[AngularのプロジェクトにFirebaseを導入する](http://qiita.com/Yamamoto0525/items/437a2884c0c51f5a3af8)
> 次記事：[AngularのNgModuleを使って、アプリの構成を管理する](http://qiita.com/Yamamoto0525/items/c647f03a5ab56883e09e)

# この記事で行うこと

本稿では、AngularのプロジェクトでCloud FirestoreのCRUD実装を行います。

- [新しいデータの作成と読み込み](http://qiita.com/Yamamoto0525/items/c1ec1b7ce2350b294aeb#%E6%96%B0%E3%81%97%E3%81%84%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E4%BD%9C%E6%88%90%E3%81%A8%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF)
- [作成したデータを編集、削除する](http://qiita.com/Yamamoto0525/items/c1ec1b7ce2350b294aeb#%E4%BD%9C%E6%88%90%E3%81%97%E3%81%9F%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E7%B7%A8%E9%9B%86%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B)

### CRUDとは

CRUDとは、ほとんど全てのコンピュータソフトウェアが持つと言われる基本的な４つの機能です。
Create（生成）、Read（読み取り）、Update（更新）、Delete（削除）の頭文字をとった言葉で、アプリケーションを作成するときには必ず抑えておくべき事項となります。

**************
（2018/1追記）RTDBの記述を現時点で最新のものに差し替えました。
（2018/9追記）angularfire2が対応したのでFirestoreの記述に差し替えました。
（2020/6追記）現時点（2020年6月）での最新の内容に書き換えています。
**************

# 実装内容

## 新しいデータの作成と読み込み

前回の記事では直接DBを更新してビューの反映を確認しましたが、今回はビューのフォームから新しい記事を追加してみようと思います。

### Cloud Firestoreの３つのレイヤー

Cloud Firestoreのデータをangularfireで扱うにあたって、**コレクション**、**ドキュメント**、**フィールド**という3つのレイヤーを覚える必要があります。

コレクションは、データの編成とクエリの作成に使用できるドキュメントのコンテナです。ドキュメントでは、単純な文字列や数値から複雑なネストオブジェクトまで、さまざまなデータタイプがサポートされており、ドキュメントが持つフィールドにデータを格納できます。

また、ドキュメント内にはサブコレクションを作成することができ、データベースの拡大に合わせて階層型データ構造の拡張を行うこともできます。

#### コレクションでよく使うメソッド

新しいコメントの追加や、コメント一覧の取得など、コレクションには格納するデータ全体に対しての処理を行うメソッドがあります。その中でもよく使用するものを下記に列記します。

| method       |       | 
|:-----------------|:------------------|
| add(value) | 一意のキーを持ったレコードを作成します。 （自動配番）|
| get(options) | コレクション配下のドキュメントとそのフィールドを取得します。|
| doc(key)  | 任意のドキュメントを指定します。 |

#### ドキュメントでよく使うメソッド

コメントの更新、削除など、特定のドキュメントに対して変更を加えるメソッドがあります。その中でもよく使用するものを下記に列記します。

| method       |       | 
|:-----------------|:------------------|
| delete() | 指定したドキュメントを削除します。|
| get(options) | 指定したドキュメントのフィールドを取得します。|
| set(data, options)  | 指定したドキュメントのフィールドを全て上書きします。 |
| update(...var_args)  | 指定したドキュメントのフィールドのうち、指定したプロパティを更新します。 |

### 新しいコメントを作成する

それではコレクションの`add()`メソッドを使って、新しいレコードを追加してみます。`src/app/app.component.ts`を次のように更新します。

```ts:src/app/app.component.ts
import { Component } from '@angular/core';
import { Comment, User } from './class/chat';
import { AngularFirestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';

const CURRENT_USER: User = new User(1, 'Tanaka Jiro');
const ANOTHER_USER: User = new User(2, 'Suzuki Taro');
const COMMENTS: Comment[] = [
  new Comment(ANOTHER_USER, 'Suzukiの１つ目のコメントです。'),
  new Comment(ANOTHER_USER, 'Suzukiの2つ目のコメントです。'),
  new Comment(CURRENT_USER, 'Tanakaの１つ目のコメントです。'),
  new Comment(ANOTHER_USER, 'Suzukiの3つ目のコメントです。'),
  new Comment(CURRENT_USER, 'Tanakaの2つ目のコメントです。')
];

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  item: Observable<Comment>;
  public content = '';
  public comments = COMMENTS;
  public currentUser = CURRENT_USER;

  // DI（依存性注入する機能を指定）
  constructor(private db: AngularFirestore) { // 更新
    this.item = db
      .collection('comments')
      .doc<Comment>('item')
      .valueChanges();
  }

  // 新しいコメントを追加
  addComment(e: Event, comment: string) { // 更新
    if (comment) {
      this.db
        .collection('comments')
        .add(new Comment(this.currentUser, comment)); // 更新
      this.content = '';
    }
  }
}
```
```html:src/app/app.component.html
    <form class="chart-form" (submit)="addComment($event, content)"><!-- $eventを追加 -->
      <div class="input-group">
        <input type="text" class="form-control"
               [(ngModel)]="content"
               name="comment"
               placeholder="Comment" />
        <div class="input-group-append">
          <button class="btn btn-info" type="submit">SEND</button>
        </div>
      </div>
    </form>
```

この状態で、フォームからコメントを追加しようとすると、次のようなエラーがコンソールに表示されます。

```bash
ERROR Error: Function CollectionReference.add() requires its first argument to be of type object, but it was: a custom Comment object
```

Cloud Firestoreの各フィールドは、`add()`されたデータの型を自動的に判別し、それぞれの型で保存を行います。この時、追加しようとしたデータはCommentクラスとなっており、この状態では保存することができないので、通常のオブジェクト型にデシリアリズする必要があります。

クラス定義、および`addComment()`を次のように修正します、

```ts:src/app/class/chat.ts
export class User {
  uid: number;
  name: string;

  constructor(uid: number, name: string) {
    this.uid = uid;
    this.name = name;
  }

  deserialize() { // 追加
    return Object.assign({}, this);
  }
}

export class Comment {
  user: User;
  initial: string;
  content: string;
  date: number;

  constructor(user: User, content: string) {
    this.user = user;
    this.initial = user.name.slice(0, 1);
    this.content = content;
    this.date = +moment();
  }

  deserialize() { // 追加
    this.user = this.user.deserialize();
    return Object.assign({}, this);
  }
}
```

```ts:src/app/app.component.ts

  // 新しいコメントを追加
  addComment(e: Event, comment: string) {
    e.preventDefault();
    if (comment) {
      this.db
        .collection('comments')
        .add(new Comment(this.current_user, comment).deserialize()); // 更新
      this.content = '';
    }
  }
```
この状態でもう一度追加を行うと、次のようになります。

![Jun-23-2020 09-45-25.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112566/519e8a94-95ee-548a-2cbb-f10282c8d61d.gif)



DBに新しいレコードが追加されるのが確認できました。
この時の配番されるキーはサーバー側で自動的に作成され、常に一意の値をとることになります。

### 作成したデータを読み込む

作成したデータをビューに反映させます。
前回の[AngularのプロジェクトにFirebaseを導入する](http://qiita.com/Yamamoto0525/items/437a2884c0c51f5a3af8)ではasyncというPipeを使ってビューに読み込みましたが、今回は`*ngFor()`にasyncを使って反映させます。また、前回テスト用に追加した記述を削除し、読み込んだ値を`set`するためにクラスも更新します。

```ts:src/app/app.component.ts
import { Component } from '@angular/core';
import { Comment, User } from './class/chat';
import { AngularFirestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; // 追加


const CURRENT_USER: User = new User(1, 'Tanaka Jiro');
const ANOTHER_USER: User = new User(2, 'Suzuki Taro');

// COMMENTSを削除

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  // itemを削除
  public content = '';
  public comments: Observable<Comment[]>; // 更新
  public currentUser = CURRENT_USER;

  // DI（依存性注入する機能を指定）
  constructor(private db: AngularFirestore) {
    this.comments = db // 更新
      .collection<Comment>('comments', ref => {
        return ref.orderBy('date', 'asc');
      })
      .snapshotChanges()
      .pipe(
        map(actions => actions.map(action => {
          // 日付をセットしたコメントを返す
          const data = action.payload.doc.data() as Comment;
          const commentData = new Comment(data.user, data.content);
          commentData.setData(data.date);
          return commentData;
        })));
  }

  // 新しいコメントを追加
  addComment(e: Event, comment: string) {
    if (comment) {
      this.db
        .collection('comments')
        .add(new Comment(this.currentUser, comment).deserialize());
      this.content = '';
    }
  }
}

```
```ts:src/app/class/chat.ts
export class Comment {
  user: User;
  initial: string;
  content: string;
  date: number;

  constructor(user: User, content: string) {
    this.user = user;
    this.initial = user.name.slice(0, 1);
    this.content = content;
    this.date = +moment();
  }

  deserialize() {
    this.user = this.user.deserialize();
    return Object.assign({}, this);
  }

  // 追加時点の日付を反映
  setData(date: number): Comment { // 追加
    this.date = date;
    return this;
  }
}
```

```html:src/app/app.component.html
  <section class="card">
    <div class="card-header">
      NgChat
    </div>
    <div class="card-body">
      <ng-container *ngFor="let comment of comments | async"><!-- asyncを追加 -->
      <div class="media">
        <div class="media-left" *ngIf="comment.user.uid !== currentUser.uid">
          <a href="#" class="icon-rounded">{{comment.initial}}</a>
        </div>
        <div class="media-body">
          <h4 class="media-heading">{{comment.user.name}} Date: {{comment.date | chatDate}}</h4>
          <div>{{comment.content}}</div>
        </div>
        <div class="media-right" *ngIf="comment.user.uid === currentUser.uid">
          <a href="#" class="icon-rounded">{{comment.initial}}</a>
        </div>
      </div>
      <hr>
      </ng-container>
      <!-- この部分を削除 -->
    </div>
  </section>
```

#### コレクションのクエリ

Firestoreはクエリを利用して取得するデータの制限や並び替えを実施することができます。
指定したいクエリをチェーンでつなぎ、左から順に評価して条件にあったデータを取得します。

なお、単一フィールドに対してクエリを使用する場合は、自動的にindexが作成されます。一方、複数のフィールドに対してクエリを使用すると、そのままではエラーが表示されて取得ができないため、Firebaseコンソールから該当するフィールドのindexを作成する必要があります。

**クエリ オプション**

| メソッド | 目的 |
|:--|:--|
| where | クエリを作成。複雑なクエリをつなぐことができます。 |
| orderBy | フィールドを指定して、データを降順、昇順に並び替えることができます。 |
| limit | 取得するデータの数の最大値をセットします。|
| startAt | 指定されたドキュメントから順に結果を取得します。（指定されたドキュメントを含む）|
| startAfter | 指定されたドキュメント以降から順に結果を取得します。（指定されたドキュメントを含まない）|
| endAt | 指定されたドキュメントまでの結果を取得します。（指定されたドキュメントを含む）|
| endBefore |指定されたドキュメント以前の結果を取得します。（指定されたドキュメントを含まない） |

**インデックス モードとクエリ句**

| インデックス モード| 説明 |
|:-----------------|:------------------|
| 昇順 |フィールドでの <、<=、==、>=、> の各クエリ句の使用と、そのフィールド値に基づいた結果の並べ替え（昇順）がサポートされます。|
| 降順 |フィールドでの <、<=、==、>=、> の各クエリ句の使用と、そのフィールド値に基づいた結果の並べ替え（降順）がサポートされます。|
|配列の内容|フィールドでの array_contains クエリ句の使用がサポートされます。|

参考：https://firebase.google.com/docs/firestore/query-data/index-overview

#### DocumentChangeAction

AngularFireでは、データの取得方法を4つのactionで指定することができます。それぞれのactionの特徴は次の通りです。

| メソッド| 概要 |
|:-----------------|:------------------|
|`valueChanges()`| Snapshotメタデータを削除し、データだけを取得したい場合に使用します。（key参照はできない）|
|`snapshotChanges()`| Snapshotメタデータを保持したままのデータを取得したい場合に使用します。（keyを含む）|
|`stateChanges()`| 上記２つのメソッドはクエリを発行した順に値を取得しますが、`stateChanges()`はイベントが発生した順に値を取得していきます。|
|`auditTrail()`| 値を取得するタイミングは`stateChanges()`と同じですが、`auditTrail()`はそれまでに発生したイベントを配列として保持します。|

参考：https://github.com/angular/angularfire2/blob/master/docs/firestore/collections.md

### 実行結果

![Jun-24-2020 12-57-38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112566/2cd85c15-0feb-9cef-f9d3-bf7526aec8b8.gif)


先ほど追加したデータがビューに反映されたのが確認できました。
別のコメントを追加すると、追加した内容が即時にビューへと反映されます。

## 作成したデータを編集、削除する

### 編集フィールドの切り替え

今度はFirebaseにアップロードしたデータを編集します。
htmlのコメント部分に編集ボタンと削除ボタンを追加し、編集ボタンを押すとそのコメントがフォームに切り替わるよう更新します。

```ts:src/app/app.component.ts
export class AppComponent {

  // itemを削除
  public content = '';
  public comments: Observable<Comment[]>;
  public currentUser = CURRENT_USER;

  // DI（依存性注入する機能を指定）
  constructor(private db: AngularFirestore) {
    this.comments = db
      .collection<Comment>('comments', ref => {
        return ref.orderBy('date', 'asc');
      })
      .snapshotChanges()
      .pipe(
        map(actions => actions.map(action => {
          // 日付をセットしたコメントを返す
          const data = action.payload.doc.data() as Comment;
          const key = action.payload.doc.id; // 追加
          const commentData = new Comment(data.user, data.content);
          commentData.setData(data.date, key); // 更新
          return commentData;
        })));
    console.log(this.comments);
  }

  // 新しいコメントを追加
  addComment(e: Event, comment: string) {
    if (comment) {
      this.db
        .collection('comments')
        .add(new Comment(this.currentUser, comment).deserialize());
      this.content = '';
    }
  }

  // 編集フィールドの切り替え
  toggleEditComment(comment: Comment) { // 追加
    comment.editFlag = (!comment.editFlag);
  }
}
```

```ts:src/app/class/chat.ts
export class Comment {
  user: User;
  initial: string;
  content: string;
  date: number;
  key?: string; // 追加
  editFlag?: boolean; // 追加

  constructor(user: User, content: string) {
    this.user = user;
    this.initial = user.name.slice(0, 1);
    this.content = content;
    this.date = +moment();
  }

  deserialize() {
    this.user = this.user.deserialize();
    return Object.assign({}, this);
  }

  // 取得した日付を反映し、更新フラグをつける
  setData(date: number, key: string): Comment {
    this.date = date;
    this.key = key; // 追加
    this.editFlag = false; // 追加
    return this;
  }
}
```
```html:src/app/app.component.html
  <section class="card">
    <div class="card-header">
      NgChat
    </div>
    <div class="card-body">
      <ng-container *ngFor="let comment of comments | async"><!-- asyncを追加 -->
        <div class="media">
          <div class="media-left" *ngIf="comment.user.uid !== currentUser.uid">
            <a href="#" class="icon-rounded">{{comment.initial}}</a>
          </div>
          <div class="media-body">
            <h4 class="media-heading">
              {{comment.user.name}} Date: {{comment.date | chatDate}}
              <button class="btn btn-primary btn-sm" (click)="toggleEditComment(comment)">編集</button><!-- 追加 -->
              <button class="btn btn-danger btn-sm">削除</button><!-- 追加 -->
            </h4>
            <!-- editFlagによって編集フィールドを切り替える -->
            <ng-container *ngIf="!comment.editFlag">
              {{comment.content}}
            </ng-container>
            <ng-container *ngIf="comment.editFlag">
              <div class="input-group">
                <input type="text" class="form-control"
                       [(ngModel)]="comment.content"
                       name="edit_comment">
              </div>
              <div class="edit-buttons">
                <button class="btn btn-success btn-sm" (click)="saveEditComment(comment)">保存</button>
                <button class="btn btn-warning btn-sm" (click)="resetEditComment(comment)">リセット</button>
              </div>
            </ng-container>
            <!-- 切り替えここまで -->
          </div>
          <div class="media-right" *ngIf="comment.user.uid === currentUser.uid">
            <a href="#" class="icon-rounded">{{comment.initial}}</a>
          </div>
        </div>
        <hr>
      </ng-container>
      <!-- この部分を削除 -->
    </div>
  </section>
```
```css:src/app/app.component.css
.page .card .media-body h4.media-heading .btn { /*追加*/
  margin: 0 3px;
}

.page .card .media-body div.edit-buttons { /*追加*/
  margin-top: 10px;
  text-align: center;
}

.page .card .media-body div.edit-buttons .btn { /*追加*/
  margin: 0 5px;
  width: 100px;
}
```

#### `Comment`クラスに`key`プロパティを追加

`Comment`クラスに`key?``edit_flag?`というプロパティを追加しました。
この`?`は、`Comment`クラス内に*なくても良い*プロパティです。
一時的に付与するプロパティを使う場合は、この`?`をつけて対応します。

### 編集した内容を更新する

編集フィールドで入力した内容をFirebaseに反映させます。
anfulafireの`update()`を使って、`content`と`date`を更新します。

```ts:src/app/app.component.ts
  // コメントを更新する
  saveEditComment(comment: Comment) { // 追加
    this.db
      .collection('comments')
      .doc(comment.key)
      .update({
        content: comment.content,
        date: comment.date
      })
      .then(() => {
        alert('コメントを更新しました');
        comment.editFlag = false;
      });
  }

  // コメントをリセットする
  resetEditComment(comment: Comment) { // 追加
    comment.content = '';
  }
```

`update()`は`set()`で登録した値を変更することができ、`set()`で登録していない値を更新しようとするとエラーになります。

### 実行結果

![Jun-24-2020 13-10-33.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112566/09ddb206-0bac-05be-f0f0-99908d12d406.gif)



### コメントを削除する

最後に、作成したコメントを`delete()`を使って削除します。

```ts:src/app/app.component.ts
  // コメントを削除する
  deleteComment(key: string) { // 追加
    this.db
      .collection('comments')
      .doc(key)
      .delete()
      .then(() => {
        alert('コメントを削除しました');
      });
  }
```
```html:src/app/app.component.html
            <h4 class="media-heading">
              {{comment.user.name}} Date:{{comment.date | chatDate}}
              <button class="btn btn-primary btn-sm" (click)="toggleEditComment(comment)">編集</button>
              <button class="btn btn-danger btn-sm" (click)="deleteComment(comment.key)">削除</button><!-- 更新 -->
            </h4>
```

### 実行結果

![Jun-24-2020 13-14-21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/112566/21b94d46-c45b-2e13-ad9a-eb8e5cd4d104.gif)


これでCreate（生成）、Read（読み取り）、Update（更新）、Delete（削除）の全てが確認できました。
次回からはAngularのモジュールとルーティングを扱っていきます。

## ソースコード

この時点での[ソースコード](https://github.com/Yamamoto0525/NgChat/commit/3a23de8c8fd3f2ddc7cecf1dde384d95cfa123eb)
※apiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。