Loading...

MBC CQRS サーバーレス フレームワーク TO-DO システム作成 5 – データの検索

前回特定でのデータの読込について記載しましたが、データベースに登録されているデータを検索する方法を今回学びたいと思います。

特定のデータの読込についてはDynamoDBを使用しましたが、データを検索する場合はRDBを使用します。
理由はDynamoDBに検索機能が限られており様々な要素で検索することが苦手なためです。

では、早速データの検索処理の実装を行ってみましょう。

検索パラメータ用DTOの追加

src/todo/dto/search-todo.dto.ts を作成し次のように入力します。

import { SearchDto } from '@mbc-cqrs-serverless/core'
import { TodoStatus } from '@prisma/client'
import { Transform } from 'class-transformer'
import { IsBoolean, IsDateString, IsEnum, IsOptional } from 'class-validator'

export class TodoSearchDto extends SearchDto {
  @IsOptional()
  @IsEnum(TodoStatus)
  status?: TodoStatus

  @IsDateString()
  @IsOptional()
  dueDate_gte?: string

  @IsDateString()
  @IsOptional()
  dueDate_lte?: string

  @IsBoolean()
  @Transform(({ value }) =>
    value === 'true' ? true : value === 'false' ? false : value,
  )
  @IsOptional()
  isDeleted?: boolean
}

一覧用Entityの追加

src/entity/todo-data-list.entity.ts を追加し以下のように入力します。

import { DataListEntity } from '@mbc-cqrs-serverless/core'

import { TodoDataEntity } from './todo-data.entity'

export class TodoDataListEntity extends DataListEntity {
  items: TodoDataEntity[]

  constructor(partial: Partial<TodoDataListEntity>) {
    super(partial)
    Object.assign(this, partial)
  }
}

サービスに検索処理を実装

src/todo/todo.service.ts に以下のように入力します。

入力されたパラメータを searchDto として受け取り、パラメータの入力状況に応じてPrismaの検索条件を指定していきます。

  async findAll(
    tenantCode: string,
    searchDto: TodoSearchDto,
  ): Promise<TodoDataListEntity> {
    const where: Prisma.TodoWhereInput = {
      isDeleted: searchDto.isDeleted ?? false,
      tenantCode,
    }
    if (searchDto.keyword?.trim()) {
      where.OR = [
        { name: { contains: searchDto.keyword.trim() } },
        { description: { contains: searchDto.keyword.trim() } },
      ]
    }

    if (searchDto.status) {
      where.status = searchDto.status
    }

    if (searchDto.dueDate_gte && searchDto.dueDate_lte) {
      where.dueDate = {
        gte: searchDto.dueDate_gte,
        lte: searchDto.dueDate_lte,
      }
    } else if (searchDto.dueDate_lte) {
      where.dueDate = {
        lte: searchDto.dueDate_lte,
      }
    } else if (searchDto.dueDate_gte) {
      where.dueDate = {
        gte: searchDto.dueDate_gte,
      }
    }

    const { pageSize = 10, page = 1, orderBys = ['-createdAt'] } = searchDto

    const [total, items] = await Promise.all([
      this.prismaService.todo.count({ where }),
      this.prismaService.todo.findMany({
        where,
        take: pageSize,
        skip: pageSize * (page - 1),
        orderBy: getOrderBys<Prisma.TodoOrderByWithRelationInput>(orderBys),
      }),
    ])

    return new TodoDataListEntity({
      total,
      items: items.map(
        (item) =>
          new TodoDataEntity({
            ...item,
            attributes: {
              description: item.description,
              dueDate: toISOStringWithTimezone(item.dueDate),
              status: item.status,
            },
          }),
      ),
    })
  }

最終的に src/todo/todo.service.ts は以下の通りとなります。

import {
  CommandService,
  DataService,
  DetailDto,
  generateId,
  getUserContext,
  IInvoke,
  toISOStringWithTimezone,
  VERSION_FIRST,
} from '@mbc-cqrs-serverless/core'
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'

import { generateTodoPk,  generateTodoSk,  getOrderBys,  TODO_PK_PREFIX,} from '../helpers'
import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoCommandEntity } from './entity/todo-command.entity'
import { TodoDataEntity } from './entity/todo-data.entity'
import { TodoSearchDto } from './dto/search-todo.dto'
import { TodoDataListEntity } from './entity/todo-data-list.entity'
import { PrismaService } from '../prisma'

@Injectable()
export class TodoService {
  private readonly logger = new Logger(TodoService.name)

  constructor(
    private readonly commandService: CommandService,
    private readonly dataService: DataService,
    private readonly prismaService: PrismaService,
  ) {}

  async create(
    createDto: CreateTodoDto,
    opts: { invokeContext: IInvoke },
  ): Promise<TodoDataEntity> {
    const { tenantCode } = getUserContext(opts.invokeContext)
    const pk = generateTodoPk(tenantCode)
    const sk = generateTodoSk()
    const task = new TodoCommandEntity({
      pk,
      sk,
      id: generateId(pk, sk),
      tenantCode,
      code: sk,
      type: TODO_PK_PREFIX,
      version: VERSION_FIRST,
      name: createDto.name,
      attributes: createDto.attributes,
    })
    const item = await this.commandService.publish(task, opts)
    return new TodoDataEntity(item as TodoDataEntity)
  }

  async findOne(detailDto: DetailDto): Promise<TodoDataEntity> {
    const item = await this.dataService.getItem(detailDto)
    if (!item) {
      throw new NotFoundException('Todo not found!')
    }
    this.logger.debug('item:', item)
    return new TodoDataEntity(item as TodoDataEntity)
  }

  async findAll(
    tenantCode: string,
    searchDto: TodoSearchDto,
  ): Promise<TodoDataListEntity> {
    const where: Prisma.TodoWhereInput = {
      isDeleted: searchDto.isDeleted ?? false,
      tenantCode,
    }
    if (searchDto.keyword?.trim()) {
      where.OR = [
        { name: { contains: searchDto.keyword.trim() } },
        { description: { contains: searchDto.keyword.trim() } },
      ]
    }

    if (searchDto.status) {
      where.status = searchDto.status
    }

    if (searchDto.dueDate_gte && searchDto.dueDate_lte) {
      where.dueDate = {
        gte: searchDto.dueDate_gte,
        lte: searchDto.dueDate_lte,
      }
    } else if (searchDto.dueDate_lte) {
      where.dueDate = {
        lte: searchDto.dueDate_lte,
      }
    } else if (searchDto.dueDate_gte) {
      where.dueDate = {
        gte: searchDto.dueDate_gte,
      }
    }

    const { pageSize = 10, page = 1, orderBys = ['-createdAt'] } = searchDto

    const [total, items] = await Promise.all([
      this.prismaService.todo.count({ where }),
      this.prismaService.todo.findMany({
        where,
        take: pageSize,
        skip: pageSize * (page - 1),
        orderBy: getOrderBys<Prisma.TodoOrderByWithRelationInput>(orderBys),
      }),
    ])

    return new TodoDataListEntity({
      total,
      items: items.map(
        (item) =>
          new TodoDataEntity({
            ...item,
            attributes: {
              description: item.description,
              dueDate: toISOStringWithTimezone(item.dueDate),
              status: item.status,
            },
          }),
      ),
    })
  }
}

コントローラの実装

src/todo/todo.controller.ts に以下のように入力しAPIを実装します。

  @Get('/')
  async findAll(
    @INVOKE_CONTEXT() invokeContext: IInvoke,
    @Query() searchDto: SearchDto,
  ) {
    this.logger.debug('searchDto:', searchDto)
    const { tenantCode } = getUserContext(invokeContext)
    return await this.todoService.findAll(tenantCode, searchDto)
  }

最終的に src/todo/todo.controller.ts は以下のようになります。

import {
  DetailDto,
  getUserContext,
  IInvoke,
  INVOKE_CONTEXT,
  SearchDto,
} from '@mbc-cqrs-serverless/core'
import {
  Body,
  Controller,
  Get,
  Logger,
  Param,
  Post,
  Query,
} from '@nestjs/common'

import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoDataEntity } from './entity/todo-data.entity'
import { TodoService } from './todo.service'

@Controller('api/todo')
export class TodoController {
  private readonly logger = new Logger(TodoController.name)

  constructor(private readonly todoService: TodoService) {}

  @Post('/')
  async create(
    @INVOKE_CONTEXT() invokeContext: IInvoke,
    @Body() createDto: CreateTodoDto,
  ): Promise<TodoDataEntity> {
    this.logger.debug('createDto:', createDto)
    return this.todoService.create(createDto, { invokeContext })
  }

  @Get('/')
  async findAll(
    @INVOKE_CONTEXT() invokeContext: IInvoke,
    @Query() searchDto: SearchDto,
  ) {
    this.logger.debug('searchDto:', searchDto)
    const { tenantCode } = getUserContext(invokeContext)
    return await this.todoService.findAll(tenantCode, searchDto)
  }

  @Get('/:pk/:sk')
  async findOne(@Param() detailDto: DetailDto): Promise<TodoDataEntity> {
    return this.todoService.findOne(detailDto)
  }
}

データ読込のテスト

test/api.http に以下を追加します。

###
GET {{apiBaseUrl}}/todo
Accept: application/json
Content-Type: application/json
Authorization: {{token}}
X-Tenant-Code: MBC

トークンを取得してから上記を実行すると下記の様な結果を得ることが出来ます。

HTTP/1.1 200 OK
x-powered-by: Express
access-control-allow-origin: *
content-type: application/json; charset=utf-8
content-length: 622
etag: W/"26e-QdmLY1x+jYpEMoZ1OvVlTlwpEaw"
cache-control: no-cache
accept-ranges: bytes
Date: Sun, 20 Oct 2024 13:18:44 GMT
Connection: close

{
  "total": 1,
  "items": [
    {
      "id": "TODO#MBC#01JAFJHS7KM8ZM50X5CX4T6517",
      "cpk": "TODO#MBC",
      "csk": "01JAFJHS7KM8ZM50X5CX4T6517@1",
      "pk": "TODO#MBC",
      "sk": "01JAFJHS7KM8ZM50X5CX4T6517",
      "tenantCode": "MBC",
      "seq": 0,
      "code": "01JAFJHS7KM8ZM50X5CX4T6517",
      "name": "Test Task 1",
      "version": 1,
      "isDeleted": false,
      "createdBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
      "createdIp": "127.0.0.1",
      "createdAt": "2024-10-18T10:25:10.000Z",
      "updatedBy": "92ca4f68-9ac6-4080-9ae2-2f02a86206a4",
      "updatedIp": "127.0.0.1",
      "updatedAt": "2024-10-18T10:25:10.000Z",
      "description": "desc",
      "status": "PENDING",
      "dueDate": null,
      "attributes": {
        "description": "desc",
        "status": "PENDING"
      }
    }
  ]
}
Top