JavaScript
Angular
Firebase
Firestore

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


この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。

前記事:AngularのプロジェクトにFirebaseを導入する

次記事:AngularのNgModuleを使って、アプリの構成を管理する



この記事で行うこと

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


CRUDとは

CRUDとは、ほとんど全てのコンピュータソフトウェアが持つと言われる基本的な4つの機能です。

Create(生成)、Read(読み取り)、Update(更新)、Delete(削除)の頭文字をとった言葉で、アプリケーションを作成するときには必ず抑えておくべき事項となります。


(2018/1追記)RTDBの記述を現時点で最新のものに差し替えました。

(2018/9追記)angularfire2が対応したのでFirestoreの記述に差し替えました。



実装内容


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

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


Cloud Firestoreの3つのレイヤー

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

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

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


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

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

method

add(value)
一意のキーを持ったレコードを作成します。 (自動配番)

get(options)
コレクション配下のドキュメントとそのフィールドを取得します。

doc(key)
任意のドキュメントを指定します。


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

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

method

delete()
指定したドキュメントを削除します。

get(options)
指定したドキュメントのフィールドを取得します。

set(data, options)
指定したドキュメントのフィールドを全て上書きします。

update(...var_args)
指定したドキュメントのフィールドのうち、指定したプロパティを更新します。


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

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


src/app/app.component.ts

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の1つ目のコメントです。'),
new Comment(ANOTHER_USER, 'Suzukiの2つ目のコメントです。'),
new Comment(CURRENT_USER, 'Tanakaの1つ目のコメントです。'),
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 current_user = 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.current_user, comment)); // 更新
this.content = '';
}
}

}



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" >
<span class="input-group-btn">
<button class="btn btn-info" type="submit">SEND</button>
</span>
</div>
</form>

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

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()を次のように修正します、


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);
}
}



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 = '';
}
}

この状態でもう一度追加を行うと、次のようになります。

Sep-21-2018 13-45-30.gif

DBに新しいレコードが追加されるのが確認できました。

この時の配番されるキーはサーバー側で自動的に作成され、常に一意の値をとることになります。


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

作成したデータをビューに反映させます。

前回のAngularのプロジェクトにFirebaseを導入するではasyncというPipeを使ってビューに読み込みましたが、今回は*ngFor()にasyncを使って反映させます。また、前回テスト用に追加した記述を削除し、読み込んだ値をsetするためにクラスも更新します。


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 current_user = 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 comment_data = new Comment(data.user, data.content);
comment_data.setData(data.date);
return comment_data;
})));
}

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



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;
}
}



src/app/app.component.html

<div class="page">

<section class="card">
<div class="card-header">
NgChat
</div>
<div class="card-block">
<ng-container *ngFor="let comment of comments | async">
<div class="media">
<div class="media-left" *ngIf="comment.user.uid !== current_user.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 === current_user.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

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

メソッド
概要

valueChanges()
Snapshotメタデータを削除し、データだけを取得したい場合に使用します。(key参照はできない)

snapshotChanges()
Snapshotメタデータを保持したままのデータを取得したい場合に使用します。(keyを含む)

stateChanges()
上記2つのメソッドはクエリを発行した順に値を取得しますが、stateChanges()はイベントが発生した順に値を取得していきます。

auditTrail()
値を取得するタイミングはstateChanges()と同じですが、auditTrail()はそれまでに発生したイベントを配列として保持します。

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


実行結果

Sep-21-2018 19-53-28.gif

先ほど追加したデータがビューに反映されたのが確認できました。

別のコメントを追加すると、追加した内容が即時にビューへと反映されます。


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


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

今度はFirebaseにアップロードしたデータを編集します。

htmlのコメント部分に編集ボタンと削除ボタンを追加し、編集ボタンを押すとそのコメントがフォームに切り替わるよう更新します。


src/app/app.component.ts

export class AppComponent {

// itemを削除
public content = '';
public comments: Observable<Comment[]>;
public current_user = 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 comment_data = new Comment(data.user, data.content);
comment_data.setData(data.date, key); // 更新
return comment_data;
})));
}

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

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

}



src/app/class/chat.ts

export class Comment {

user: User;
initial: string;
content: string;
date: number;
key?: string; // 追加
edit_flag?: 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.edit_flag = false; // 追加
return this;
}
}



src/app/app.component.html

  <section class="card">

<div class="card-header">
NgChat
</div>
<div class="card-block">
<ng-container *ngFor="let comment of comments | async"><!-- asyncを追加 -->
<div class="media">
<div class="media-left" *ngIf="comment.user.uid !== current_user.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>
<!-- edit_flagによって編集フィールドを切り替える -->
<ng-container *ngIf="!comment.edit_flag">
{{comment.content}}
</ng-container>
<ng-container *ngIf="comment.edit_flag">
<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 === current_user.uid">
<a href="#" class="icon-rounded">{{comment.initial}}</a>
</div>
</div>
<hr>
</ng-container>
<!-- この部分を削除 -->
</div>
</section>


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に反映させます。

anfulafire2のupdate()を使って、contentdateを更新します。


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.edit_flag = false;
});
}

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


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


実行結果

Sep-21-2018 21-33-24.gif


コメントを削除する

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


src/app/app.component.ts

  // コメントを削除する

deleteComment(key: string) { // 追加
this.db
.collection('comments')
.doc(key)
.delete()
.then(() => {
alert('コメントを削除しました');
});
}


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>


実行結果

Sep-21-2018 21-38-18.gif

これでCreate(生成)、Read(読み取り)、Update(更新)、Delete(削除)の全てが確認できました。

次回からはAngularのモジュールとルーティングを扱っていきます。


ソースコード

この時点でのソースコード

※apiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。