📚

【NestJS】TODOリストでCRUDの処理を実装

2021/09/27に公開
4

今回はNestJSを使って、TODOリストを題材にCRUDのロジックを実装します。

OSはMacを使います。NodeとYarnが既にインストールされていることを前提に進めます。今回の記事で扱うツールなどのバージョンは以下の通りです。

Node yarn NestJS MySQL
16.4.2 1.22.11 16.4.2 8.0.26

プロジェクトの作成

nestコマンドを実行できるように@nestjs/cliをインストールします。

そしてnest newコマンドを実行し、nest-todolistプロジェクトを作成します。作成途中で「どのパッケージマネージャーを使うか?」と聞かれるため、yarnを選択します。

❯ yarn global add @nestjs/cli
❯ nest new nest-todolist

データ操作関連のライブラリをインストール

TODOリストの保存先のDBは、MySQLを使います。データを扱う際にはTypeORMを使用し、入力データのバリデーションにclass-validatorを使います。

❯ cd nest-todolist
❯ yarn
❯ yarn add @nestjs/typeorm typeorm mysql class-transformer class-validator

TypeORMとは、TypeScriptで記述できるORマッパーライブラリです。特徴などについては、以下の記事が分かりやすかったので貼っておきます。
https://qiita.com/tejitak/items/b6965380afd600db6513

TypeORMとDBの設定

まずは、NestJSからTypeORMを使用出来るようにします。app.module.tsを編集して、全てのモジュールからTypeORMの機能を呼び出せるようにします。

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
+ import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
-  imports: [],
+  imports: [TypeOrmModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

プロジェクトのルート直下にTypeORMの設定ファイル、ormconfig.jsonを作成します。

❯ touch ormconfig.json

DBの接続情報を書いていきます。

ormconfig.json
{
  "type": "mysql",
  "host": "localhost",
  "port": 3306,
  "username": "root",
  "password": "root",
  "database": "todolist",
  "entities": ["dist/entities/**/*.entity.js"],
  "migrations": ["dist/migrations/**/*.js"],
  "logging": false
}

https://github.com/typeorm/typeorm/blob/master/docs/using-ormconfig.md

テーブル定義

TODOリストを保存するための、テーブルの定義は以下のようにしました。

テーブル名:task

カラム論理名 カラム物理名
ID task_id integer
タイトル title varchar
期日 due_date date
状態 status tinyint
作成日 created_at datetime
更新日 updated_at datetime

状態カラムは数値として定義しています。数値にしたのは1なら未着手、2なら着手中、3なら完了と表示するためです。文字列で保存してしまうと、仕様変更で名前を変えたくなった時に、これまで保存していたデータに影響を与えますし、文字数の分、無駄にDBの容量を圧迫してしまうため数値にしています。

ローカルにMySQLをインストール

MySQLをインストールする流れは、割愛します。以下の記事が参考になりそうなので、そちらをご確認ください。そして、パスワードの設定からログインまで実行してください。

https://chiritsumo-blog.com/mac-mysql-install/

今回作成するユーザー名とパスワードは、開発環境でしか使わないことを想定しrootで統一します。MySQLにログイン出来たらデータベースの定義だけ作っておきます。

mysql> CREATE DATABASE todolist;
Query OK, 1 row affected (0.00 sec)

mysql> exit;
Bye

Entityの作成

次にEntityを作成します。Entityとはテーブルの構造をクラス構文で表現したものです。

テーブルの作成は、MySQLのCREATE TABLEの実行ではなく、Entityをもとにマイグレーションファイルを作成し、それを実行してテーブルを作成します。そのため、Entityが必要になります。

src/entities/task.entity.tsとなるように、ディレクトリとファイルを作成します。

❯ mkdir src/entities
❯ touch $_/task.entity.ts

$_を使うと、ひとつ前に実行したコマンドラインの最後の引数を参照してくれます。つまりtouch src/entities/task.entity.tsと同じことをしています。

task.entity.ts
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class Task {
  @PrimaryGeneratedColumn()
  readonly task_id: number;

  @Column('varchar', { length: 20, nullable: false })
  title: string;

  @Column('date', { nullable: false })
  due_date: Date;

  @Column('tinyint', { width: 1, default: 1 })
  status: number;

  @CreateDateColumn()
  readonly created_at?: Date;

  @UpdateDateColumn()
  readonly updated_at?: Date;
}

SQLのテーブル定義とほぼ同じなので、SQLが分かれば構文はそんなに難しくはないです。例えば、titleカラムの定義は、データ型はvarcharで20文字まで、nullを許可しないといった感じです。

readonlyは、インサート後にアプリケーションからデータを更新したくない時に使います。他の詳しい内容は以下が参考になりますので、ご確認ください。

https://github.com/typeorm/typeorm/blob/master/docs/entities.md

マイグレーションファイルを作成

マイグレーションファイルを作成します。-nは必須のパラメータでファイル名を指定します。

❯ yarn build
❯ yarn typeorm migration:generate -d src/migrations -n create-task

実行結果

yarn run v1.22.11
$ /Users/username/dev/nest-todolist/node_modules/.bin/typeorm migration:generate -d src/migrations -n create-task
Migration /Users/takaakichida/Dev/nest-todolist/src/migrations/1632644470874-create-task.ts has been generated successfully.
✨  Done in 1.18s.

「generated successfully」と出ているので、問題なく作成出来たようです。

マイグレーションファイルを実行

マイグレーションファイルを実行します。

❯ yarn build
❯ yarn typeorm migration:run

実行結果

$ /Users/username/dev/nest-todolist/node_modules/.bin/typeorm migration:run
query: SELECT * FROM `INFORMATION_SCHEMA`.`COLUMNS` WHERE `TABLE_SCHEMA` = 'todolist' AND `TABLE_NAME` = 'migrations'
query: CREATE TABLE `todolist`.`migrations` (`id` int NOT NULL AUTO_INCREMENT, `timestamp` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB
query: SELECT * FROM `todolist`.`migrations` `migrations` ORDER BY `id` DESC
0 migrations are already loaded in the database.
1 migrations were found in the source code.
1 migrations are new migrations that needs to be executed.
query: START TRANSACTION
query: CREATE TABLE `todolist`.`task` (`task_id` int NOT NULL AUTO_INCREMENT, `title` varchar(20) NOT NULL, `due_date` date NOT NULL, `status` tinyint(1) NOT NULL DEFAULT '1', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`task_id`)) ENGINE=InnoDB
query: INSERT INTO `todolist`.`migrations`(`timestamp`, `name`) VALUES (?, ?) -- PARAMETERS: [1632644470874,"createTask1632644470874"]
Migration createTask1632644470874 has been executed successfully.
query: COMMIT
✨  Done in 1.33s.

MySQLにログインして、作成されたテーブルを見てみます。

❯ mysql -uroot -proot
mysql> use todolist;
mysql> show tables;
+--------------------+
| Tables_in_todolist |
+--------------------+
| migrations         |
| task               |
+--------------------+
2 rows in set (0.01 sec)

mysql> desc task;
+------------+-------------+------+-----+----------------------+--------------------------------------------------+
| Field      | Type        | Null | Key | Default              | Extra                                            |
+------------+-------------+------+-----+----------------------+--------------------------------------------------+
| task_id    | int         | NO   | PRI | NULL                 | auto_increment                                   |
| title      | varchar(20) | NO   |     | NULL                 |                                                  |
| due_date   | date        | NO   |     | NULL                 |                                                  |
| status     | tinyint(1)  | NO   |     | 1                    |                                                  |
| created_at | datetime(6) | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED                                |
| updated_at | datetime(6) | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED on update CURRENT_TIMESTAMP(6) |
+------------+-------------+------+-----+----------------------+--------------------------------------------------+
6 rows in set (0.00 sec)

taskテーブルが問題なく作成されていることを確認しました。

Controllerの作成

Controllerの役割は、指定したパスでリクエストを受け取りレスポンスを返すことです。次のコマンドでControllerを作成します。コマンドのgはgenerateのaliasです。

Controllerはtask.module.tsファイルのControllersに登録することで、使えるようになります。(module.tsファイルは後で作成します)

❯ yarn nest g controller task

src/task/task.controller.tsというファイルが作成されます。

Controllers | NestJS - A progressive Node.js framework

Serviceの作成

Serviceでは、Controllerによりルーティングされたリクエストの処理を行います。次のコマンドでServiceを作成します。

Serviceはtask.module.tsファイルのprovidersに登録することで、ControllerでServiceの定義を行えます。

❯ yarn nest g service task

src/task/task.service.tsというファイルが作成されます。

Providers | NestJS - A progressive Node.js framework

Moduleの作成

Moduleは、ControllerやServiceの依存関係を管理します。moduleファイルに対して、ControllerやServiceを登録することで、そのControllerやServiceが使えるようになります。

Applicationモジュールがルートとなり、下位に各機能のモジュールが存在するイメージです。

次のコマンドでModuleを作成します。

❯ yarn nest g module task

src/task/task.module.tsというファイルが作成されますので、これを編集します。

src/task/task.module.ts
import { Module } from '@nestjs/common';
import { TaskController } from './task.controller';
import { TaskService } from './task.service';
+ import { Task } from 'src/entities/task.entity';
+ import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  controllers: [TaskController],
+  imports: [TypeOrmModule.forFeature([Task])],
  providers: [TaskService],
})
export class TaskModule {}

Modules | NestJS - A progressive Node.js framework

DTOの作成

DTOにはデータをやり取りする時の構造やバリデーションを定義します。

❯ touch src/task/task.dto.ts
src/task/task.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateTaskDTO {
  @IsNotEmpty()
  @IsString()
  title: string;

  @IsNotEmpty()
  @IsString()
  due_date: string;
}

@IsNotEmpty()@IsString()の説明については、以下をご確認ください。

https://www.npmjs.com/package/class-validator

そしてmain.tsを編集し、DTOを使ったバリデーションを有効にします。

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
+ import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
+  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

CreateとReadの処理を実装

CRUDのCreateとReadの処理を実装するため、task.service.tsを編集します。

ServiceにRepositoryを注入します。RepositoryがDBのテーブルとやり取りをするため、ServiceではRepositoryに対し、CRUDの処理を命令する構造になります。

@InjectRepository(Task)と書けば、DIコンテナが登録されたRepositoryを生成して渡してくれます。これでtaskRepositoryに対して指示を出すと、テーブルに反映出来ます。

src/task/task.service.ts
import { Injectable } from '@nestjs/common';
+ import { Task } from 'src/entities/task.entity';
+ import { Repository } from 'typeorm';
+ import { InjectRepository } from '@nestjs/typeorm';

@Injectable()
export class TaskService {
+   constructor(
+     @InjectRepository(Task)
+     private readonly taskRepository: Repository<Task>,
+   ) {}
}

次にCRUDのロジックをServiceに書きます。task.service.tsを編集します。

src/task/task.service.ts
import { Injectable } from '@nestjs/common';
import { Task } from 'src/entities/task.entity';
+ import { Repository, InsertResult } from 'typeorm'; 
import { InjectRepository } from '@nestjs/typeorm';
+ import { CreateTaskDTO } from './task.dto';

@Injectable()
export class TaskService {
  constructor(
    @InjectRepository(Task)
    private readonly taskRepository: Repository<Task>
  ) {}

+  async findAll(): Promise<Task[]> {
+    return await this.taskRepository.find();
+  }

+  async create(Task: CreateTaskDTO): Promise<InsertResult> {
+    return await this.taskRepository.insert(Task);
+  }

+  async find(id: number): Promise<Task> | null {
+    return await this.taskRepository.findOne({ task_id: id });
+  }
}

ControllerからのServiceの呼び出し

コントローラにサービスを注入します。DIコンテナがTaskService型の引数があれば、自動で生成されます。CreateとReadのルーティングを実装します。

src/task/task.controller.ts
- import { Controller } from '@nestjs/common';
+ import { Controller, Get, Post, Body, Param } from '@nestjs/common';
+ import { TaskService } from './task.service';
+ import { Task } from '../entities/task.entity';
+ import { CreateTaskDTO } from './task.dto';
+ import { InsertResult } from 'typeorm';

@Controller('task')
export class TaskController {
+  constructor(private readonly service: TaskService) {}

+  @Get()
+  async getTaskList(): Promise<Task[]> {
+    return await this.service.findAll();
+  }

+  @Post()
+  async addTask(@Body() task: CreateTaskDTO): Promise<InsertResult> {
+    return await this.service.create(task);
+  }

+  @Get(':id')
+  async getTask(@Param('id') id: string): Promise<Task> {
+    return await this.service.find(Number(id));
+  }
}

動作確認

先ほど実行したCreateとReadの処理の動作確認を行います。

❯ yarn start:dev

ブラウザで http://localhost:3000/task にアクセスし、データ挿入前のため、まずは空の配列が表示されるかを確認します。

ブラウザ上の画面

[]

次に新規データを挿入します。ターミナルでcurlコマンドを実行してください。

curlコマンドの実行:1回目

❯ curl http://localhost:3000/task -X POST -d "title=ジムに行く&due_date=2021-10-05"

curlコマンドの実行:2回目

❯ curl http://localhost:3000/task -X POST -d "title=NestJSの学習を終わらせる&due_date=2021-10-08"

ターミナルに表示されるの実行結果:1回目

{"identifiers":[{"task_id":1}],"generatedMaps":[{"task_id":1,"status":1,"created_at":"2021-09-26T13:04:14.550Z","updated_at":"2021-09-26T13:04:14.550Z"}],"raw":{"fieldCount":0,"affectedRows":1,"insertId":1,"serverStatus":2,"warningCount":0,"message":"","protocol41":true,"changedRows":0}}

ターミナルに表示されるの実行結果:2回目

{"identifiers":[{"task_id":2}],"generatedMaps":[{"task_id":2,"status":1,"created_at":"2021-09-26T13:07:29.018Z","updated_at":"2021-09-26T13:07:29.018Z"}],"raw":{"fieldCount":0,"affectedRows":1,"insertId":2,"serverStatus":2,"warningCount":0,"message":"","protocol41":true,"changedRows":0}}

ブラウザで http://localhost:3000/task にアクセスします。挿入した2つのデータが表示されます。

[{"task_id":1,"title":"ジムに行く","due_date":"2021-10-05","status":1,"created_at":"2021-09-26T13:04:14.550Z","updated_at":"2021-09-26T13:04:14.550Z"},{"task_id":2,"title":"NestJSの学習を終わらせる","due_date":"2021-10-08","status":1,"created_at":"2021-09-26T13:07:29.018Z","updated_at":"2021-09-26T13:07:29.018Z"}]

次にidを指定して確認します。ブラウザで http://localhost:3000/task/2 にアクセスします。そうするとidが2のデータだけ表示されました。

[{"task_id":2,"title":"NestJSの学習を終わらせる","due_date":"2021-10-08","status":1,"created_at":"2021-09-26T13:07:29.018Z","updated_at":"2021-09-26T13:07:29.018Z"}]

UpdateとDeleteの処理を実装

CRUDのUpdateとDeleteの処理を実装します。

src/task/task.service.ts
import { Injectable } from '@nestjs/common';
import { Task } from 'src/entities/task.entity';
- import { Repository, InsertResult } from 'typeorm';
+ import { Repository, InsertResult, UpdateResult, DeleteResult } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { CreateTaskDTO } from './task.dto';

export class TaskService {
  constructor(
    @InjectRepository(Task)
    private readonly taskRepository: Repository<Task>,
  ) {}

  async findAll(): Promise<Task[]> {
    return await this.taskRepository.find();
  }

  async create(Task: CreateTaskDTO): Promise<InsertResult> {
    return await this.taskRepository.insert(Task);
  }

  async find(id: number): Promise<Task> | null {
    return await this.taskRepository.findOne({ task_id: id });
  }

+   async update(id: number, Task): Promise<UpdateResult> {
+     return await this.taskRepository.update(id, Task);
+   }

+   async delete(id: number): Promise<DeleteResult> {
+     return await this.taskRepository.delete(id);
+   }
}

class CreateTaskDTOの下に追加します。

src/task/task.dto.ts
export class UpdateTaskDTO {
  @IsOptional()
  @IsNotEmpty()
  @IsString()
  title: string;

  @IsOptional()
  @IsNotEmpty()
  @IsString()
  due_date: string;
}
src/task/task.controller.ts
- import { Controller, Get, Post, Body, Param } from '@nestjs/common';
+ import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
- import { CreateTaskDTO } from './task.dto';
+ import { CreateTaskDTO, UpdateTaskDTO } from './task.dto';
- import { InsertResult } from 'typeorm';
+ import { InsertResult, UpdateResult, DeleteResult } from 'typeorm';

@Controller('task')
export class TaskController {
  constructor(private readonly service: TaskService) {}

  @Get()
  async getTaskList(): Promise<Task[]> {
    return await this.service.findAll();
  }

  @Post()
  async addTask(@Body() task: CreateTaskDTO): Promise<InsertResult> {
    return await this.service.create(task);
  }

  @Get(':id')
  async getTask(@Param('id') id: string): Promise<Task> {
    return await this.service.find(Number(id));
  }

+  @Put(':id/update')
+  async update(
+    @Param('id') id: string,
+    @Body() task: UpdateTaskDTO,
+  ): Promise<UpdateResult> {
+    return await this.service.update(Number(id), task);
+  }

+  @Delete(':id')
+  async deleteTask(@Param('id') id: string): Promise<DeleteResult> {
+    return await this.service.delete(Number(id));
+  }
}

Updateの処理を確認します。

❯ curl http://localhost:3000/task/2/update -X PUT -d "title=Reactの学習を終わらせる"

Deleteの処理を確認します。

❯ curl http://localhost:3000/task/1 -X DELETE

それぞれブラウザで http://localhost:3000/task を確認して、UpdateとDeleteが行われていれば成功です。これで一通り、CRUDの処理を実装できました。

実装したコードは こちら にあげています。

参考

https://docs.nestjs.com/
https://typeorm.io/#/
https://zenn.dev/morinokami/articles/nestjs-overview

Discussion

ばんばらばんばら

マイグレーションファイルを作成の項目を実行したところ
typeorm migration:generate <path>

Generates a new migration file with sql needs to be executed to update schema.

オプション:
-h, --help ヘルプを表示 [真偽]
-d, --dataSource Path to the file where your DataSource instance is
defined. [文字列] [必須]
-p, --pretty Pretty-print generated SQL [真偽] [デフォルト: false]
-o, --outputJs Generate a migration file on Javascript instead of
Typescript [真偽] [デフォルト: false]
--dr, --dryrun Prints out the contents of the migration instead of
writing it to a file [真偽] [デフォルト: false]
--ch, --check Verifies that the current database is up to date and that
no migrations are needed. Otherwise exits with code 1.
[真偽] [デフォルト: false]
-t, --timestamp Custom timestamp for the migration name
[数値] [デフォルト: false]
-v, --version バージョンを表示 [真偽]

オプションではない引数が 0 個では不足しています。少なくとも 1 個の引数が必要です:
error Command failed with exit code 1.
このようなエラーが発生してしまいますが、こちらは環境に依存するものなのでしょうか?
また、解決方法等をご存知ないでしょうか?

TCTC

こちらのissueにあるように、typeormのバージョンが新しいとそのようなエラーが出るようです。
https://github.com/typeorm/typeorm/issues/8810

この記事の手順で試してみたいのであれば、取り急ぎpackage.jsonに書かれているtypeormのバージョンを"typeorm": "^0.2.37"にしていただいて、yarn installをし直してみていただけますか。

あまり良い解決方法ではないかもしれませんが、記事が半年前のものですので...

TakaoTakao

こちらの件、メモベースですが、解消方法わかったので、コメントします。
・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜
公式ドキュメントの手順に従って、src/data-source.tsを作成します。
migrationsは以下のように指定しないと動かない気がします。

migrations['src/migrations']

公式ドキュメントの手順を参考にpackage.jsonscriptsに以下を追加します。

"typeorm": "typeorm-ts-node-commonjs",

以下のコマンドを実行します。

npm run typeorm migration:run -- -d src/data-source.ts

・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜・〜

たぶんこれでmigration:runコマンド自体はエラーなく動かせると思います。