7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プライム・ブレインズAdvent Calendar 2024

Day 6

GraphQLで正規化されていないデータを整形する ――Resolverの責務について――

Last updated at Posted at 2024-12-05

普段、GraphQLを用いてAPI開発をしているのですが、正規化されていないデータベースからレコードを取り出し、それを編集して綺麗なスキーマに載せ替えるようなAPIを作成することが度々あります。
このようなケースを取り扱う場合、各Resolverにどこまでの責務をもたせるべきかについて、混乱が生じることも多いと感じます。そこで今回は、具体的なケース例を取り上げつつ、自分なりの考え方を整理してみようと思います。

前提

データベースはこちら。都道府県、市区町村ごとの住民テーブルですが、正規化されていません。

prefectureId prefectureName cityId cityName residentId residentName
p001 Tokyo c001 Shinjuku r001 Taro Yamada
p001 Tokyo c001 Shinjuku r002 Hanako Suzuki
p001 Tokyo c002 Shibuya r003 Kenji Tanaka
p002 Osaka c003 Umeda r004 Mika Sato

データ型はこちら。

entityModelResident.ts
export interface EntityModelResident {
    residentId: string;
    residentName: string;
    cityId: string;
    cityName: string;
    prefectureId: string;
    prefectureName: string;
}

これを次のGraphQLスキーマに載せ替えます。

image.png

スキーマファイルはこちら。

schema.gql
type Resident {
  """住民ID"""
  id: String!

  """住民名"""
  name: String!
}

type City {
  """市区町村ID"""
  id: String!

  """市区町村名"""
  name: String!

  """住民"""
  residents: [Resident!]!
}

type Prefecture {
  """都道府県ID"""
  id: String!

  """都道府県名"""
  name: String!

  """市区町村"""
  cities: [City!]!
}

type Query {
  prefectures(userId: String): [Prefecture!]!
}

設計方針

では、上記の前提をもとに、各Resolverで何をどこまで実装するべきかを検討していきましょう。Resolverは各TypeとQuery/Mutationに紐づいて作成されるため、今回は以下の4つとなります。

Type 作成するResolver
Query (prefectures) PrefecturesResolver
Prefecture PrefectureResolver
City CityResolver
Resident ResidentResolver

Resolver設計のポイントは、各Resolverが、自分よりも下位のResolverの仕事を奪ってしまわないようにすることです。

例えば、極端な設計を考えると、最上位のPrefecturesResolverでPrefecture、City、Residentという3つのノード全てを完成させることもできます。しかしその場合は、

query {
  prefectures(userId: "admin"){
    id
    name
  }
}

このようなQueryがリクエストされた際に、不要であるはずの処理(例えば「住民単位で元データをグループ化する」処理など)が動いてしまうことになり、パフォーマンスが最適化されません。

したがって、様々なリクエストのパターンに応じて、最適なパフォーマンスを発揮することを重視するのであれば、各Resolverはできるだけ下位のResolverに処理を委譲すべきです。

それを実現するために、各Resolverがやるべきことは

  • 担当するTypeの子ノードが、葉ノード(子ノードをもたないノード)であれば、最後まで完成させる
  • 担当するTypeの子ノードが、内部ノード(子ノードをもつノード)であれば、子ノードをキーごとに区切る

の2つです。逆に言うと内部ノードを完成させるのは責任超過ということになります。

上記のアイデアに基づいて、今回作成するResolverとその責務を以下にまとめました。

Resolver 責務
ResidentResolver Residentノードに紐づく葉ノード(id, name)を完成させる。
CityResolver Cityノードに紐づく葉ノード(id, name)を完成させる。住民IDごとに元データ EntityModelResident[] をグループ化する。
PrefectureResolver Prefectureノードに紐づく葉ノード(id, name)を完成させる。市区町村IDごとに元データ EntityModelResident[] をグループ化する。
PrefecturesResolver 正規化されていないテーブルからデータを取得する。都道府県IDごとに元データ EntityModelResident[] をグループ化する。

この考え方は、テーブルが正規化されている場合にも当てはまります。正規化されている場合、各Resolverの責務は次のようになります。

Resolver 責務
ResidentResolver Residentノードに紐づく葉ノード(id, name)を完成させる。
CityResolver Cityノードに紐づく葉ノード(id, name)を完成させる。住民テーブルからデータを取得する。
PrefectureResolver Prefectureノードに紐づく葉ノード(id, name)を完成させる。市区町村テーブルからデータを取得する。
PrefecturesResolver 都道府県テーブルからデータを取得する。

正規化されていれば、データを取得した時点で当然「子ノードがキーごとに区切られている」状態になるので、やっていることは同じです。

逆に言うと、正規化されていない場合も、正規化されている場合と同じタイミングでグループ化すればいいだけと考えると、混乱しなくてよいかもしれません。

実装のポイント

では、上記の設計方針に基づいて、各Resolverを作成していきます。

まず、PrefecturesResolverを作成します。
Prefecturesの子ノードは、Prefectureです。Prefectureは中間ノードなので、キーであるprefectureId単位でデータをグルーピングしてあげます。

prefectures.resolver.ts
import { Query, Resolver } from '@nestjs/graphql';
import { Prefecture } from '../prefecture/models/prefecture.model';
import { EntityModelResident } from './models/entityModelResident';
import { map, Observable, of } from 'rxjs';

@Resolver(() => [Prefecture])
export class PrefecturesResolver {
    @Query(() => [Prefecture])
    public prefectures(): Observable<Prefecture[]> {
        return this.fetchMockData().pipe(
            map((residents) => {
                // 都道府県単位でデータをグループ化
                // key: prefectureId, value: PrefectureのMapを作成
                const prefectureMap = new Map<string, Prefecture>();

                residents.forEach((resident) => {
                    const prefectureId = resident.prefectureId;

                    // 都道府県をマップに追加(初回のみ)
                    if (!prefectureMap.has(prefectureId)) {
                        prefectureMap.set(prefectureId, {
                            id: prefectureId,
                            name: resident.prefectureName,
                            cities: residents.filter((resident) => resident.prefectureId === prefectureId) // ここではcitiesを解決しない(PrefectureResolverの責務)
                        });
                    }
                })

                return Array.from(prefectureMap.values());
            })
        );
    }

    /**
     * Backend APIを呼び出すメソッドのモック
     * @returns 居住する都道府県、市区町村の情報をもった住民の一覧
     */
    private fetchMockData(): Observable<EntityModelResident[]> {
        return of([ 
          //前提に記載したモックデータ 
        ]);
    }
}

次に、PrefectureResolverを作成します。
Prefectureの子ノードは、id, name, citiesです。idとnameは葉ノードなので完成させる必要がありますが、parentから渡ってきた同名のプロパティをセットするだけなので、省略可能です。
citiesは中間ノードなので、先ほど同様にキーであるcityId単位でデータをグルーピングしてあげます。

prefecture.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Prefecture } from './models/prefecture.model';
import { City } from '../city/models/city.model';

@Resolver(() => Prefecture)
export class PrefectureResolver {
    @ResolveField(() => [City])
    public cities(@Parent() parent: Prefecture): City[] {
        const cities = parent.cities;

        // 市区町村単位でデータをグループ化
        // key: cityId, value: CityのMapを作成
        const cityMap = new Map<string, City>();

        cities.forEach((city) => {
            const cityId = city.cityId;

            // 市区町村をマップに追加(初回のみ)
            if (!cityMap.has(cityId)) {
                cityMap.set(cityId, {
                    id: cityId,
                    name: city.cityName,
                    residents: cities.filter((city) => city.cityId === cityId) // ここではresidentsを解決しない(CityResolverの責務)
                });
            }
        })

        return Array.from(cityMap.values());
    }
}

ちなみに、idとnameを省略しない場合は、このように書きます。

prefecture.resolver.ts
    // 同名のプロパティをセットするだけなので、省略可能
    @ResolveField(() => String, { description: '都道府県ID', nullable: false })
    public id(@Parent() parent: Prefecture): string {
        return parent.id;
    }

    // 同名のプロパティをセットするだけなので、省略可能
    @ResolveField(() => String, { description: '都道府県名', nullable: false })
    public name(@Parent() parent: Prefecture): string {
        return parent.id;
    }

次に、CityResolverを作成します。ここまでくれば、こなれたものです。
Cityの子ノードは、id, name, residentsです。idとnameは葉ノードなので完成させる必要がありますが、parentから渡ってきた同名のプロパティをセットするだけなので、省略可能です。
residentsは中間ノードなので、先ほど同様にキーであるresidentId単位でデータをグルーピングしてあげます。

city.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { City } from './models/city.model';
import { Resident } from '../resident/models/resident.model';

@Resolver(() => City)
export class CityResolver {
    @ResolveField(() => [Resident])
    public residents(@Parent() parent: City): Resident[] {
        const residents = parent.residents

        // 住民単位でデータをグループ化
        // key: residentId, value: ResidentのMapを作成
        const residentMap = new Map<string, Resident>();

        residents.forEach((resident) => {
            const residentId = resident.residentId;

            // 住民をマップに追加(初回のみ)
            if (!residentMap.has(residentId)) {
                residentMap.set(residentId, {
                    id: residentId,
                    name: resident.residentName,
                });
            }
        })

        return Array.from(residentMap.values());
    }
}

最後に、ResidentResolverを作成します。
Residentの子ノードは、id, nameです。idとnameは葉ノードなので完成させる必要がありますが、parentから渡ってきた同名のプロパティをセットするだけなので、省略可能です。

resident.resolver.ts
import { Resolver } from '@nestjs/graphql';
import { Resident } from './models/resident.model';

@Resolver(() => Resident)
export class ResidentResolver { }

以上で、完成です。

↓それでは、サーバを起動してリクエストを投げてみましょう。

query {
  prefectures{
    id
    name
    cities {
        id
        name
        residents {
            id
            name
        }
    }
  }
}

↓すると、きちんとグルーピングされて返ってきました。

{
    "data": {
        "prefectures": [
            {
                "id": "p001",
                "name": "p001",
                "cities": [
                    {
                        "id": "c001",
                        "name": "Shinjuku",
                        "residents": [
                            {
                                "id": "r001",
                                "name": "Taro Yamada"
                            },
                            {
                                "id": "r002",
                                "name": "Hanako Suzuki"
                            }
                        ]
                    },
                    {
                        "id": "c002",
                        "name": "Shibuya",
                        "residents": [
                            {
                                "id": "r003",
                                "name": "Kenji Tanaka"
                            }
                        ]
                    }
                ]
            },
            {
                "id": "p002",
                "name": "p002",
                "cities": [
                    {
                        "id": "c003",
                        "name": "Umeda",
                        "residents": [
                            {
                                "id": "r004",
                                "name": "Mika Sato"
                            }
                        ]
                    }
                ]
            }
        ]
    }
}

以上、正規化されていないデータベースからレコードを取り出し、それを編集して綺麗なスキーマに載せ替えるケースにおける、各Resolverの責務の考え方でした。

実装例

詳細なソースコードを見たい方は、こちらをどうぞ。

7
2
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
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?