🌟

Nest.js+TypeORM+PostgreSQLで実装してみた

2024/10/12に公開

概要

最近、業務でNest.jsを使用しており、理解を深めるために、Nest.jsとTypeScriptのライブラリであるTypeORMを使ってデータベース操作を実践してみたいと思います。内容が少し複雑で難しい部分があるため、できる限り解説を挟んでいきますが、不十分な説明や知識不足の箇所があるかもしれません。その点、ご了承いただけると幸いです。

機能

Nest.js

  • TypeScriptベースでNode.jsのフレームワーク
  • モジュール構造
    https://nestjs.com/

TypeORM

  • クエリを直接書く必要がない(今回はベタ書きします)
  • エンティティ
  • DBマイグレーション
    https://typeorm.io/

Nest.jsの導入

以下をターミナルで実行します。インストールが完了したらnestコマンドが使えるようになります。

npm i -g @nestjs/cli

その後新規プロジェクトを立ち上げます。今回は例としてTodo機能を追加するので名前はTodo_projectとします。

nest new Todo_project

TypeORMの導入

TypeORMのインストールを実行します。pgはPostgreSQLの略です。DBとの接続に必要です。

npm install @nestjs/typeorm typeorm pg

TypeORMの接続先は以下です。
synchronizeをtrueに設定すると、アプリケーションが起動するたびにエンティティ定義に基づいて自動的にデータベースのテーブル構造を更新します。本番環境では通常 false にします。

todo.module.ts
TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get<string>('DATABASE_HOST'),
        port: configService.get<number>('DATABASE_PORT'),
        username: configService.get<string>('DATABASE_USER'),
        password: configService.get<string>('DATABASE_PASSWORD'),
        database: configService.get<string>('DATABASE_NAME'),
        entities: [__dirname + '/../**/*.entity{.ts,.js}'],
        synchronize: true,
      }

.envファイルには以下のようなDB接続情報を設定します。

.env
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres_user
DATABASE_PASSWORD=secret_password
DATABASE_NAME=my_database

Nest.jsの構成要素作成

Todo.moduleの記述
moduleとはアプリケーションの構成を管理するための要素です。Nest.jsではすべての機能がmoduleに含まれています。moduleは、関連するcontrollerserviceをグループ化し、1つのまとまりとして管理します。

TodoController,TodoService,PostTodoをimportしmoduleに定義します。
このmoduleはTodoアプリケーションの機能を提供するために必要なコンポーネントを登録し、データベースへの接続設定などを行います。

Todo.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TodoController } from './todo.controller';
import { TodoService } from './todo.service';
import { PostTodo } from './dto/todo.table';

@Module({
  imports: [
    TypeOrmModule.forFeature([PostTodo]),
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get<string>('DATABASE_HOST'),
        port: configService.get<number>('DATABASE_PORT'),
        username: configService.get<string>('DATABASE_USER'),
        password: configService.get<string>('DATABASE_PASSWORD'),
        database: configService.get<string>('DATABASE_NAME'),
        entities: [__dirname + '/../**/*.entity{.ts,.js}'],
        synchronize: true,
      }),
    }),
  ],
  controllers: [TodoController],
  providers: [TodoService],
})
export class AppModule {}

Todo.controllerの記述
Get,Postリクエストを呼び出し、エンドポイントを提供しています。これらはTodoServiceを利用してバックエンドのデータ操作を行い、クライアントからのリクエストに対してTodoの作成、取得、編集、削除を行います。

TodoServicePostTodoをimportします。

Todo.controller.ts
import { PostTodo } from './dto/todo.table';
import { TodoService } from './todo.service';
import {
  Body,
  Controller,
  Get,
  HttpCode,
  Post,
  Delete,
  Param,
} from '@nestjs/common';

@Controller('todo')
export class TodoController {
  constructor(private readonly appService: TodoService) {}

  @Get('getList')
  async getList(): Promise<PostTodo[]> {
    return this.appService.getList();
  }
  @Post('registration')
  @HttpCode(201)
  async create(@Body() post: Omit<PostTodo, 'id'>): Promise<void> {
    await this.appService.create(post);
  }

  @Post('edit/:id')
  async edit(
    @Param('id') id: string,
    @Body() updatedPost: PostTodo,
  ): Promise<string> {
    return this.appService.edit(id, updatedPost);
  }

  @Delete('delete/:id')
  async delete(@Param('id') id: string): Promise<string> {
    return this.appService.delete(id);
  }
  @Post('createTable')
  async createTable(): Promise<void> {
    console.log('create完了');
    await this.appService.createTable();
  }
}

Todo.serviceの記述
serviceとは、ビジネスロジックやDBとのやり取りなど、具体的な処理を行う部分です。外部APIの呼び出しなどを行います。

Todo.service.ts
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { PostTodo } from './dto/todo.table';

@Injectable()
export class TodoService {
  constructor(
    @InjectEntityManager() private readonly entityManager: EntityManager,
  ) {}

  async createTable(): Promise<void> {
    try {
      await this.entityManager.query(`
        CREATE TABLE post_todo (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          task VARCHAR(255) NOT NULL,
          detail TEXT NOT NULL,
          isToggle BOOLEAN DEFAULT false,
        );
      `);
    } catch (e) {
      console.error(e);
    }
  }

  async getList(): Promise<PostTodo[]> {
    try {
      const res = await this.entityManager.query('SELECT * FROM post_todo');
      return res;
    } catch (e) {
      console.error(e);
    }
  }

  async create(post: Omit<PostTodo, 'id'>): Promise<void> {
    try {
      await this.entityManager.query(
        `INSERT INTO post_todo (task, detail, "isToggle") VALUES ($1, $2, $3)`,
        [post.task, post.detail, post.isToggle],
      );
    } catch (e) {
      console.error(e);
    }
  }

  async edit(id: string, updatedPost: PostTodo): Promise<string> {
    try {
      const post = await this.entityManager.query(
        `SELECT * FROM post_todo WHERE id = $1`,
        [id],
      );

      if (post.length === 0) {
        return 'Post not found';
      }

      await this.entityManager.query(
        `UPDATE post_todo SET task = $1, detail = $2, "isToggle" = $3 WHERE id = $4`,
        [updatedPost.task, updatedPost.detail, updatedPost.isToggle, id],
      );

      return 'Post updated successfully';
    } catch (e) {
      console.error(e);
    }
  }

  async delete(id: string): Promise<string> {
    try {
      const post = await this.entityManager.query(
        `SELECT * FROM post_todo WHERE id = $1`,
        [id],
      );

      if (post.length === 0) {
        return 'Post not found';
      }

      await this.entityManager.query(`DELETE FROM post_todo WHERE id = $1`, [
        id,
      ]);

      return 'Post deleted successfully';
    } catch (e) {
      console.error(e);
    }
  }
}

ここで一つポイントですがcreateTableメソッド内でentityManager.queryというメソッドがあります。これはTypeORMのEntityManagerクラスを使用してデータベースに対して生のSQLクエリを実行するためのメソッドです。これを使う理由は特定のEntityに依存せずにカスタムSQLクエリを直接実行したいときに使用されます。

個人の見解としてはメンテナンス性、セキュリティの観点からこの書き方はあまり推奨はしませんが、複雑なクエリやパフォーマンス向上が必要な場合に便利なので今回はこの書き方で実行します。

Todo.service.ts
async createTable(): Promise<void> {
    try {
      await this.entityManager.query(`
        CREATE TABLE post_todo (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          task VARCHAR(255) NOT NULL,
          detail TEXT NOT NULL,
          isToggle BOOLEAN DEFAULT false,
        );
      `);
    } catch (e) {
      console.error(e);
    }
  }

Todo.table(Entity)の記述
このEntityはTypeORMを使用して定義されたもので、クラスフィールドはテーブルのカラムに対応しており、CRUD操作に使用されます。

Todo.table.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity('post_todo')
export class PostTodo {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  task: string;

  @Column({ type: 'text' })
  detail: string;

  @Column({ type: 'boolean', default: false })
  isToggle: boolean;

}

mainの記述
今回はlocalhost:3011で開きます。

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './todo.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3011);
}
bootstrap();

apiの実装

今回はシンプルなローカル環境で実装するのでPostmanを使って実装します。
Postリクエストを選択しhttp://localhost:3011/todo/createTablePostします。コンソールにcreate完了と出力されたらTableができます。
次に先ほどの同じようにPostリクエストを選択しhttp://localhost:3011/todo/registrationを書き、JSON形式でBODYに

{
  "task": "買い物をする",
  "detail": "パン、野菜",
  "isToggle": false
}

と記述しPostリクエストをします。
その後Getリクエストを選択しhttp://localhost:3011/todo/getListを記述しリクエストを送ると先ほどリクエストしたtodoがレスポンスとして返ってきます。この時のidはuuidなのでランダムで生成されます。

このようにレスポンスが返ってきたら実装完了です!

最後に

バックエンドでの実装では理解に苦しみましたがなんとか記事にすることができました。正直フロントエンドよりバックエンドでコード書いてる方が個人的には好きです。

間違いや不適切な部分がございましたらご指摘いただけると嬉しいです。

Discussion