0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NextとNestで型安全な部分一致検索取得

Posted at

【検索編】フリーワード検索フロー完全解説 (1. バックエンド層)

### はじめに:これから何をするのか?
このドキュメントでは、BFFから**「このキーワードに部分一致する社員を、personalId, name, memoカラムから検索してください」**というgRPCリクエストが来たときに、バックエンド内部で何が起きるのか、その全工程を解き明かします。

### 今回のゴール:
* フリーワードを受け取り、複数のカラムを対象に**部分一致(LIKE検索)**を行う。
* 検索結果として、該当するユーザーのリストを返す。

### バックエンドのフロー: gRPCリクエスト -> Controller -> Service -> Repository -> Prisma -> DB
この基本的な流れは、これまでの機能実装と全く同じです。違いは、Repository層でのPrismaのクエリの組み立て方にあります。

### Step 1: 契約の更新 - 「社員を検索する」という新しいルールを決める
まず、BFFとの通信ルール(gRPCスキーマ)を更新し、「フリーワードで社員を検索する」という新しい操作を定義します。

**レイヤー:** Backend (gRPC契約定義)  
**ファイル:** `apps/backend/src/proto/template/user.proto`

```protobuf
// 📍 レイヤー: Backend (Protocol Buffers)
// 📂 ファイル: apps/backend/src/proto/template/user.proto

syntax = "proto3";
package user;
import "google/protobuf/empty.proto";

// ... 既存のUser, CreateUserRequestなど ...
// Userのリストを返すためのレスポンスメッセージ(既存のものがあれば再利用)
message ListUsersResponse {
  repeated User users = 1;
}


// --- ↓ここから追記 ---

// 「社員を検索して」というリクエストの形
message SearchUsersRequest {
  // 検索キーワード。空文字列の場合もあるのでoptionalが適切。
  optional string keyword = 1;
}

// 既存のUserServiceに新しい機能を追加
service UserService {
  // ... 既存の rpc CreateUser, GetUser ...

  // SearchUsersという名前でやり取りします、と宣言
  rpc SearchUsers(SearchUsersRequest) returns (ListUsersResponse);
}

ここでの解説:

  • rpc SearchUsers(...): これが新しい検索機能の名前です。
  • SearchUsersRequest: この機能が受け取る入力です。検索キーワードをkeywordとして含みます。optionalにすることで、キーワードが空(全件検索のようなケース)の場合も扱えるようにしています。
  • returns (ListUsersResponse): 検索結果として、ユーザーの配列を返すことを定義しています。

次に行うこと: このファイルを保存した後、pnpm proto-setupを実行し、この新しいルールに対応したTypeScriptの型を自動生成します。


Step 2: リポジトリの実装 - Prismaによる複数カラム部分一致検索

レイヤー: Backend
ファイル: apps/backend/src/features/user/user.repository.ts

Serviceから「このキーワードで検索して」と依頼を受けた際に、実際にPrismaを使ってデータベースを検索する層です。ここが今回の実装の核心部分です。

// 📍 レイヤー: Backend (Repository)
// 📂 ファイル: apps/backend/src/features/user/user.repository.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { User as DomainUser } from './domain/User';
import { User as PrismaUser } from '@prisma/client';

@Injectable()
export class UserRepository {
  constructor(private readonly prisma: PrismaService) {}

  // ... 既存のcreate, findByIdなど ...

  // --- ↓ここから追記 ---

  // 解説: Serviceから、検索キーワードを受け取ります。
  async search(keyword: string | undefined): Promise<DomainUser[]> {
    // 1. Prisma Clientの`findMany`メソッドを使います。
    const userEntities = await this.prisma.user.findMany({
      // ★★★ここが、複数カラム部分一致検索の心臓部★★★
      where: {
        // `OR`を使うことで、配列で指定した条件の「いずれか」に一致するレコードを検索します。
        OR: [
          {
            // personalIdカラムに、keywordが「含まれている」レコード
            personalId: {
              contains: keyword,
              // `mode: 'insensitive'` を指定すると、大文字・小文字を区別しない検索(ILIKE)になります。
              mode: 'insensitive', 
            },
          },
          {
            // nameカラムに、keywordが「含まれている」レコード
            name: {
              contains: keyword,
              mode: 'insensitive',
            },
          },
          {
            // memoカラムに、keywordが「含まれている」レコード
            memo: {
              contains: keyword,
              mode: 'insensitive',
            },
          },
        ],
        // AND条件として、論理削除されていないユーザーのみを対象にする
        deletedAt: null,
      },
      // 検索結果の並び順を指定
      orderBy: {
        createdAt: 'desc',
      },
    });

    // 2. 検索結果の「Prismaの型」の配列を、「Domainの型」の配列に変換して返します。
    return userEntities.map(entity => this.toDomain(entity));
  }

  // 既存の変換メソッド
  private toDomain(entity: PrismaUser): DomainUser { /* ... */ }
}

ここでの解説:

  • where: { OR: [ ... ] }: Prismaで「A または B または C」という条件を実現するための構文です。これにより、複数のカラムを横断した検索が可能になります。
  • contains: keyword: SQLのLIKE '%keyword%'と同じ意味で、指定した文字列がカラムのどこかに含まれているレコードを探します。これが「部分一致検索」の正体です。
  • mode: 'insensitive': 多くのデータベースで、大文字と小文字を区別しない検索を実行するためのオプションです。'user'という検索語で'User'や'USER'にもヒットするようになります。

Step 3: サービスの実装 - 検索ロジックの呼び出し

レイヤー: Backend
ファイル: apps/backend/src/features/user/user.service.ts

Controllerから依頼を受け、検索キーワードを検証し、Repositoryに検索を依頼します。

// 📍 レイヤー: Backend (Service)
// 📂 ファイル: apps/backend/src/features/user/user.service.ts

import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { User as DomainUser } from './domain/User';

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  // ... 既存のcreate, updateなど ...

  // --- ↓ここから追記 ---

  async search(keyword: string | undefined): Promise<DomainUser[]> {
    // 【ビジネスロジック】
    // 例えば、検索キーワードが短すぎる場合はエラーにする、などのルールをここに追加できます。
    // if (keyword && keyword.length < 2) {
    //   throw new BadRequestException('検索キーワードは2文字以上で入力してください。');
    // }

    // Repositoryに検索処理を依頼します。
    return this.userRepository.search(keyword);
  }
}

Step 4: コントローラーの実装 - gRPCリクエストの受付

レイヤー: Backend
ファイル: apps/backend/src/features/user/user.controller.ts

BFFからのgRPCリクエストを最初に受け取る「窓口」です。

// 📍 レイヤー: Backend (Controller)
// 📂 ファイル: apps/backend/src/features/user/user.controller.ts

import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { UserService } from './user.service';
import { 
  User as ProtoUser,
  SearchUsersRequest,
  ListUsersResponse 
} from 'src/proto/interface/user.proto';
import { User as DomainUser } from './domain/User';

@Controller()
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ... 既存のcreateUser, updateUserなど ...

  // --- ↓ここから追記 ---

  @GrpcMethod('UserService', 'SearchUsers')
  async searchUsers(request: SearchUsersRequest): Promise<ListUsersResponse> {
    // 1. 受け取ったリクエストからキーワードを取り出し、そのままServiceに渡します。
    //    `request.keyword`は`optional`なので、`undefined`の可能性があります。
    const domainUsers = await this.userService.search(request.keyword);

    // 2. Serviceから返ってきた「ドメインモデル」の配列を、
    //    BFFに返すための「Protoの型」の配列に変換します。
    const protoUsers = domainUsers.map(user => this.domainToProto(user));

    // 3. 変換後の配列を、.protoで定義したレスポンスの形(`{ users: [...] }`)に整形して返します。
    return { users: protoUsers };
  }
 
  // 既存の変換関数
  private domainToProto(user: DomainUser): ProtoUser { /* ... */ }
}

これで、バックエンド側の「フリーワード検索」機能の全ての土台が完成しました。BFFからgRPCリクエストが来れば、複数のカラムを対象とした部分一致検索を実行し、結果を返すことができます。



【検索編】フリーワード検索フロー完全解説 (2. BFF編)

はじめに:BFFの「翻訳者」としての役割

このドキュメントでは、フロントエンドから**「'山田'というキーワードで社員を検索してください」**というGraphQLリクエストが来たときに、BFF内部で何が起きるのか、その全工程を解き明かします。

BFFは、フロントエンドが話す「GraphQL」という言語と、バックエンドが話す「gRPC」という言語の間に立つ、非常に優秀な**「通訳者」**です。両者の言語(プロトコル)と語彙(型)の違いを吸収し、スムーズな会話を実現します。

BFFのフロー: GraphQLリクエスト -> Resolver -> Service -> gRPCクライアント -> Backendへ

Step 1: GraphQLスキーマの定義 - フロントエンドとの「契約書」を作る

レイヤー: BFF (GraphQLスキーマ定義 - Code First)
ファイル: bff/src/features/user/user.resolver.ts (追記)

「Code First」アプローチでは、TypeScriptのコード(リゾルバ)がAPI仕様の「原本」となります。user.resolver.tsに新しいクエリを追加します。

// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/user/user.resolver.ts

import { Resolver, Query, Args } from '@nestjs/graphql';
import { BffUserService } from './user.service';
import { User } from './models/user.model';

@Resolver(() => User)
export class UserResolver {
  constructor(private readonly userService: BffUserService) {}

  // ... 既存のuser(id: ID!)クエリなど ...

  // --- ↓ここから追記 ---

  // 解説: GraphQLスキーマの`Query`型に、`searchUsers`という名前の新しいクエリを追加します。
  //       戻り値は、Userモデルの配列であることを示します。
  @Query(() => [User], { name: 'searchUsers' })
  async searchUsers(
    // @Argsデコレータで、GraphQLクエリの引数を受け取ります。
    // 'keyword'という名前で、オプショナルなString型として受け取ることを宣言します。
    @Args('keyword', { type: () => String, nullable: true }) keyword?: string,
  ): Promise<User[]> {
    // Resolverの仕事は、受け取った引数をそのままServiceに渡すことだけです。
    return this.userService.search(keyword);
  }
}

ここでの解説:

  • @Query(() => [User], { ... }): このメソッドが、Userの配列を返すQueryであることを定義します。
  • @Args('keyword', ...): フロントエンドからsearchUsers(keyword: "...")という形で渡される引数を受け取るための定義です。nullable: trueなので、キーワードが指定されない場合(undefined)も許容します。

次に行うこと: このファイルを保存すると、pnpm start:devが動いていれば、BFFが提供するschema.graphqlが自動的に更新され、searchUsers(keyword: String): [User!]!というクエリが追加されます。


Step 2: サービスの実装 - 「プロトコル翻訳」の実行

レイヤー: BFF
ファイル: bff/src/features/user/user.service.ts

Resolverから依頼を受け、実際にBackendへgRPCリクエストを送信する「翻訳者」です。

// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/user/user.service.ts

import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
// ★ gRPCの型をインポート
import {
  UserServiceClient,
  USER_SERVICE_NAME,
  SearchUsersRequest,
  ListUsersResponse,
  User as ProtoUser,
} from 'src/generated/proto/user';
// ★ GraphQLの型をインポート
import { User as GraphQLUser } from './models/user.model';

@Injectable()
export class BffUserService implements OnModuleInit {
  private backendUserService!: UserServiceClient;

  constructor(
    // DIで、'USER_PACKAGE'という名前で登録されたgRPCクライアントを注入してもらいます。
    @Inject('USER_PACKAGE') private readonly client: ClientGrpc,
  ) {}

  // gRPCクライアントの初期化
  onModuleInit() {
    this.backendUserService = this.client.getService<UserServiceClient>(USER_SERVICE_NAME);
  }

  // ... 既存のcreate, update, findByIdなど ...

  // --- ↓ここから追記 ---

  // 解説: Resolverから、オプショナルな検索キーワードを受け取ります。
  async search(keyword?: string): Promise<GraphQLUser[]> {
    // 1. 【翻訳①】GraphQLの世界の引数(`keyword`)を、gRPCの世界の型(`SearchUsersRequest`)に変換します。
    const request: SearchUsersRequest = {
      keyword: keyword, // keywordがundefinedでも、optionalなので問題ありません。
    };

    // 2. 準備しておいたgRPCクライアントを使い、Backendの`SearchUsers`メソッドを呼び出します。
    const response: ListUsersResponse = await firstValueFrom(
      this.backendUserService.searchUsers(request),
    );
   
    // バックエンドから返ってきた`ProtoUser`の配列を取り出します。
    // レスポンスが空の場合も考慮して、`|| []`を付けておくとより安全です。
    const protoUsers = response.users || [];

    // 3. 【翻訳②】Backendから返ってきたgRPCの型(`ProtoUser[]`)の配列を、
    //    フロントエンドに返すGraphQLの型(`GraphQLUser[]`)の配列に変換します。
    //    この変換(マッピング)処理が、BFFのサービスが担う重要な責務です。
    const graphqlUsers: GraphQLUser[] = protoUsers.map(protoUser => {
      return {
        id: protoUser.id,
        name: protoUser.name,
        email: protoUser.email,
        employeeNumber: protoUser.employeeNumber,
        // ... 他に必要なプロパティをマッピング ...
      };
    });

    // 4. 完全に翻訳された、フロントエンドが期待する形のデータ配列をResolverに返します。
    return graphqlUsers;
  }
}

ここでの解説:

  • searchメソッドの追加: Resolverからの要求に応えるため、keywordを引数に取る新しいメソッドを追加しました。
  • 翻訳①(リクエスト): const request: SearchUsersRequest = { keyword }の部分で、GraphQLの引数をgRPCリクエストのメッセージに変換しています。
  • gRPC呼び出し: this.backendUserService.searchUsers(request)で、Backendに定義したSearchUsers RPCを正確に呼び出しています。
  • 翻訳②(レスポンス): protoUsers.map(...)の部分で、Backend語のデータ(ProtoUser)を、フロントエンド語のデータ(GraphQLUser)に一つ一つ丁寧に変換しています。これにより、たとえBFFとBackendでUserのデータ構造が異なっていても、フロントエンドには常に一貫したデータを提供できます。

Step 3: モジュールとDIの確認

これらの新しい機能や依存関係が正しく動作するように、モジュールファイルを確認します。ほとんどの場合、このステップでは既存のファイルに修正は不要ですが、全体の連携を理解するために重要です。

  • bff/src/features/user/user.module.ts:
    このモジュールがUserResolverBffUserServiceprovidersとして持ち、ClientsModuleimportsしていることを確認します。
  • bff/src/grpc-client.options.ts:
    USER_PACKAGEに対応するgRPCクライアントの定義が存在することを確認します。
  • bff/src/app.module.ts:
    ルートモジュールがUserModuleimportsしていることを確認します。

これで、BFF側の「フリーワード検索」機能の準備が完了です。BFFは、フロントエンドからのsearchUsers(keyword: "...")というGraphQLリクエストを受け取り、Backendから取得した検索結果を返せるようになりました。



【検索編】フリーワード検索フロー完全解説 (3. フロントエンド編)

はじめに:フロントエンドの役割とゴール

このドキュメントでは、ユーザーが検索ボックスにキーワードを入力し、「検索」ボタンを押したときに、BFFから条件に一致する社員の一覧を取得して画面に表示するまでの、フロントエンド内部の全工程を解き明かします。

フロントエンドのフロー:

GraphQLクエリ定義 -> pnpm codegen -> 型/フック自動生成 -> カスタムフックでロジックをカプセル化 -> UIコンポーネントで検索を実行・結果を表示

Step 1: 「欲しいものリスト」の作成 - GraphQLオペレーションを定義する

まず、BFFから**「このキーワードで社員を検索してください」**という「注文書」をGraphQLで定義します。

レイヤー: Frontend (GraphQL定義)
ファイル: apps/frontend/src/graphql/operations/user.graphql (追記)

# 📍 レイヤー: Frontend
# 📂 ファイル: apps/frontend/src/graphql/operations/user.graphql

# ... 既存のGetUserById クエリなど ...

# --- ↓ここから追記 ---

# 「SearchUsers」という名前のクエリ(注文書)を定義
# このクエリは、オプショナルな`keyword`を引数として受け取ります。
query SearchUsers($keyword: String) {
  # BFFの`searchUsers(keyword: ...)`というAPIを呼び出してください
  searchUsers(keyword: $keyword) {
    # 返してほしいユーザーのフィールドはこれだけです
    id
    employeeNumber
    name
    email
  }
}

ここでの解説:

  • $keyword: String: このクエリがkeywordという名前の変数を、String型として受け取ることを示します。!が付いていないので、この引数はオプショナル(省略可能)です。
  • searchUsers(keyword: $keyword): 受け取った$keyword変数を、BFFのsearchUsersクエリの引数として渡しています。

次に行うこと: このファイルを保存した後、pnpm --filter frontend codegenを実行します。これにより、次のステップで使う「魔法の道具」がgenerated.tsに自動生成されます。


Step 2: データ取得ロジックのカプセル化 - カスタムフックの作成

次に、UIコンポーネントが直接データ取得のロジックを持たないように、責務をカスタムフックに分離します。「検索ボタンが押された時だけ」クエリを実行するため、URQLのuseQuerypauseオプションを活用するのがポイントです。

レイヤー: Frontend (カスタムフック)
ファイル: apps/frontend/src/hooks/useUserSearch.ts (新規作成)

// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/hooks/useUserSearch.ts

import { useState } from 'react';
import { useQuery } from 'urql';
// ★Step 1の`pnpm codegen`で自動生成された「魔法の道具」をインポート
import {
  SearchUsersQuery,
  SearchUsersQueryVariables,
  SearchUsersDocument,
} from '@/graphql/generated';

export const useUserSearch = () => {
  // 1. 検索キーワードを保持するためのstate
  const [keyword, setKeyword] = useState<string | undefined>(undefined);
 
  // 2. クエリを実行するかどうかを制御するためのstate
  const [isPaused, setIsPaused] = useState(true);

  // 3. URQLのuseQueryフックに、自動生成された「型」と「注文書」を渡します。
  const [result, executeQuery] = useQuery<SearchUsersQuery, SearchUsersQueryVariables>({
    query: SearchUsersDocument,
    variables: { keyword },
    // ★★★ここが最重要ポイント★★★
    // `pause`が`true`の間は、このクエリは自動で実行されません。
    pause: isPaused,
  });
 
  // 4. UIコンポーネントから呼び出すための、検索実行関数
  const searchUsers = (searchKeyword: string) => {
    // 検索キーワードをセットし、クエリの実行停止を解除する
    setKeyword(searchKeyword);
    setIsPaused(false);
   
    // `executeQuery`を呼び出すことで、最新の状態で再フェッチを強制することもできます。
    // (通常はpauseと変数の変更で自動実行されるので、必須ではない)
    // executeQuery({ requestPolicy: 'network-only' });
  };

  // UIコンポーネントが使いやすいように、結果と関数を返します。
  return {
    users: result.data?.searchUsers,
    loading: result.fetching,
    error: result.error,
    searchUsers, // 検索実行関数
  };
};

ここでの解説:

  • pause: isPaused: これが「ボタンを押した時だけ実行する」を実現する鍵です。最初はisPausedtrueなので、コンポーネントが表示されてもクエリは飛びません。
  • searchUsers関数: UIからこの関数が呼び出されると、setKeywordで検索語が設定され、setIsPaused(false)でクエリの実行が許可されます。variablesであるkeywordが変わったことで、URQLは自動的にBFFへの通信を開始します。

Step 3: UIコンポーネントの実装 - 検索フォームと結果表示

いよいよ最終段階です。作成したカスタムフックを使って、ユーザーがキーワードを入力し、検索を実行し、結果を表示するUIを構築します。

レイヤー: Frontend (UIコンポーネント)
ファイル: apps/frontend/src/components/UserSearch.tsx (新規作成)

// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/components/UserSearch.tsx

'use client';

import { useState } from 'react';
import {
  Box, Input, Button, Stack, Text, Spinner, Table, Thead, Tbody, Tr, Th, Td
} from '@chakra-ui/react';
import { useUserSearch } from '@/hooks/useUserSearch';

export const UserSearch = () => {
  // 1. 作成した検索用フックを呼び出す
  const { users, loading, error, searchUsers } = useUserSearch();
 
  // 2. 検索インプットの入力値を管理するためのローカルstate
  const [searchTerm, setSearchTerm] = useState('');

  // 3. 「検索」ボタンがクリックされたときの処理
  const handleSearchClick = () => {
    // カスタムフックが公開しているsearchUsers関数を実行
    searchUsers(searchTerm);
  };

  return (
    <Box>
      <Stack direction="row" spacing={4} mb={6}>
        <Input
          placeholder="社員番号, 氏名, メモで検索..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') handleSearchClick();
          }}
        />
        <Button onClick={handleSearchClick} colorScheme="blue" isLoading={loading}>
          検索
        </Button>
      </Stack>

      {/* --- ▼ 検索結果の表示エリア ▼ --- */}
      {loading && <Spinner />}
     
      {error && <Text color="red.500">エラーが発生しました: {error.message}</Text>}
     
      {/* 検索結果が存在する場合のみテーブルを表示 */}
      {users && (
        <Table variant="simple">
          <Thead>
            <Tr>
              <Th>社員番号</Th>
              <Th>氏名</Th>
              <Th>メールアドレス</Th>
            </Tr>
          </Thead>
          <Tbody>
            {users.length === 0 ? (
              <Tr><Td colSpan={3}>該当するユーザーは見つかりませんでした。</Td></Tr>
            ) : (
              // ★★★型安全なデータアクセス★★★
              // `user.`と打った瞬間に、id, name, email, employeeNumberが補完される
              users.map(user => (
                <Tr key={user.id}>
                  <Td>{user.employeeNumber}</Td>
                  <Td>{user.name}</Td>
                  <Td>{user.email}</Td>
                </Tr>
              ))
            )}
          </Tbody>
        </Table>
      )}
    </Box>
  );
};

ここでの解説:

  • useState for Input: 検索ボタンが押されるまでAPIリクエストは飛ばしたくないので、インプットの現在の値はUIコンポーネント内のsearchTermというローカルなstateで管理します。
  • handleSearchClick: この関数が、UIのイベントとデータ取得ロジック(カスタムフック)を結びつける「橋渡し」です。ボタンがクリックされたら、現在の入力値searchTermを引数にしてsearchUsers関数を呼び出します。
  • 結果のレンダリング: loading, error, usersという、カスタムフックから返された状態に応じて、スピナー、エラーメッセージ、検索結果のテーブルを条件付きで表示します。

これで、フロントエンドからバックエンドまで、ユーザーがキーワードで検索して結果を表示する、という一連のフローが全て繋がりました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?