見出し画像

Patterns of Enterprise Application Architecture から学ぶ - Chapter 3

はじめに

こんにちは。ソフトウェアエンジニアのttokutakeと申します。

これはPatterns of Enterprise Application Architecureという本を読んでみて、自分が理解した内容を要約して書き起こしていくシリーズの3回目の記事です。

全18回のシリーズとなる予定です。
方針を変更して、全8回のシリーズとなる予定です。

第3回を迎えてみてわかりましたが、Chapter 1~8までの内容を記事にするうえで、Chapter 9~18の内容もある程度は読み込まないとコード例が書けないことわかりました。そのため、Chapter 1~8までの内容を記事にするまでを目標にしようと思います。

間違っている部分などがありましたら、ご指摘いただけますと幸いです。

注意事項

  • 対象読者は主にWebアプリケーションのエンジニアです。

  • 本の内容をそのまま記事に記載しているわけではありません。

    • 内容のすべてを記載すると情報量が多いように感じたので、省略している部分がそれなりにあります。

    • 自分自身の見解を述べている箇所もあります。

Chapter 3. Mapping to Relational Databases

データソースレイヤーではさまざまなインフラストラクチャーとデータのやりとりをします。その中でもメインとなるのがデータベースです。

特にリレーショナルデータベースは今でも一番使われています。今回はそんなリレーショナルデータベースにまつわる話です。

今回の内容は、今まで以上に本に書かれている内容を省いていますが、それでもとんでもなく長くなってしまっています。興味のない箇所は適宜読み飛ばしていただければと思います。

Transaction Scriptのコード例を載せています。前回の記事 と同様に TypeScriptDeno による実装です。

アーキテクチャパターン

基本的な考えとして、ドメインロジック内でSQLを直接発行せず、分離されたクラスで発行するほうが良いというものがあります。これは以下のような理由からです。

  • 本来的には関係ないSQL発行などの操作をしないことで、ドメインロジックはビジネスに必要な計算の実装に集中できます。例えば、ショッピングカート内の商品の合計金額を計算するときに、「ショッピングカート内のアイテム」というデータが必要なのであって、「ショッピングカート内のアイテムをSQLで取得する」というふるまいは必要ありません。

  • DBAが必要になるような大規模アプリケーションでは、SQLのチューニングやインデックスの整理については分業できるようにしたほうが効率が良い(らしい)です。

では4つのパターンを通して、どのように分離するかを見ていきます。

  • Row Data Gateway

  • Table Data Gateway

  • Active Record

  • Data Mapper

Row Data Gatewayはデータとオブジェクト指向が自然にマッチしているアプローチです。1レコード1インスタンスという対応です。

コード例を示していきます。以下のように "crews" テーブルがあることを想定していただければと思います。

// crews

 id | name  |   bounty
----+-------+------------
  1 | Luffy | 1500000000
  2 | Zoro  |  320000000

Row Data Gatewayのコード例は以下のようになります。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class CrewGateway {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async update(): Promise<void> {
    await client.queryArray`
      UPDATE crews
      SET name=${this.name}, bounty=${this.bounty}
      WHERE id=${this.id}
    `;
  }

  async delete(): Promise<void> {
    await client.queryArray`
      DELETE FROM crews
      WHERE id=${this.id}
    `;
  }

  static async find(id: number): Promise<CrewGateway> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new CrewGateway(row.id, row.name, row.bounty);
  }
}

以下のように使います。テストコードは こちら です。

const data = new CrewGateway(1, "Nami", BigInt(60_000_000));
await data.insert();
const data = await CrewGateway.find(1);
data.name = "Usopp";
await data.update();

Table Data GatewayはRecord Setを用いるアプローチです。1テーブル1インスタンスという対応です。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

type RecordSet = CrewsRow[];

export class CrewGateway {
  async insert(id: number, name: string, bounty: bigint): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${id}, ${name}, ${bounty})
    `;
  }

  async update(id: number, name: string, bounty: bigint): Promise<void> {
    await client.queryArray`
      UPDATE crews
      SET name=${name}, bounty=${bounty}
      WHERE id=${id}
    `;
  }

  async delete(id: number): Promise<void> {
    await client.queryArray`
      DELETE FROM crews
      WHERE id=${id}
    `;
  }

  async find(id: number): Promise<RecordSet> {
    const { rows } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    return rows;
  }
}

使い方は以下のようになります。 テストコードは こちら です。

const crewGateway = new CrewGateway();
await crewGateway.insert(1, "Nami", BigInt(60_000_000));
const crewGateway = new CrewGateway();
await crewGateway.update(1, "Usopp", BigInt(200_000_000));

前回の記事で紹介しているTable Module を利用する場合はTable Data Gatewayを用いるのが一般的です。また、Table Data Gatewayはストアドプロシージャに相当する操作を集約するパターンと考えることもできます。DBに直接ストアドプロシージャを定義した場合でもTable Data Gatewayでラップしてあげるとカプセル化できるのでオススメです。

2つのGatewayパターンはDomain Modelと同時に利用するのはオススメされません。その理由は残り2つのパターンのほうがDomain Modelとの相性が良いからです。では残り2つのパターンを紹介していきます。

Active Recordは Ruby on Rails でおなじみのパターンです。Row Data Gatewayにドメインロジックを加えたものとイメージするとわかりやすいです。

例えばRow Data Gatewayと比べて、"isDanger()" のようなドメインロジックが追加されていくイメージです。使い方は テストコード をご参照ください。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class Crew {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async update(): Promise<void> {
    await client.queryArray`
      UPDATE crews
      SET name=${this.name}, bounty=${this.bounty}
      WHERE id=${this.id}
    `;
  }

  async delete(): Promise<void> {
    await client.queryArray`
      DELETE FROM crews
      WHERE id=${this.id}
    `;
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Crew(row.id, row.name, row.bounty);
  }

  isDanger(): boolean {
    return this.bounty >= 1_000_000_000;
  }
}

Active Recordはドメインロジックが簡単なうちはとても扱いやすく重宝しますが、ドメインロジックが複雑になればなるほど、以下のような理由から厳しい状況に陥りやすいです。

  • ドメインロジックがより小さなクラスに洗練されていくとテーブルと1対1対応しなくなってしまう。

  • リレーショナルデータベースでは継承が使えないので デザインパターン の利用が難しいときがある。

  • 複雑になればなるほど、データベースを介さずにドメインロジックをテストしたくなる。

GatewayパターンはActive Modelの問題をある程度解決してくれますが、依然としてデータベースのカラムとDomain Modelのフィールドを相互変換する処理をドメインロジックに押し付けることになります。これはドメインロジックを複雑なものにしてしまいます。

Data Mapperはデータのロードとセーブに加えて、変換の処理も担うので、データベースとDomain Modelの依存を分離してくれます。

コード例を以下に示します。簡単な例にしているため、フィールドとカラムの複雑なマッピングはありませんがご了承ください。

import { client } from "../postgres_client.ts";

import { Crew } from "./domain.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class CrewMapper {
  async insert(crew: Crew) {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${crew.id}, ${crew.name}, ${crew.bounty})
    `;
  }

  async update(crew: Crew): Promise<void> {
    await client.queryArray`
      UPDATE crews
      SET name=${crew.name}, bounty=${crew.bounty}
      WHERE id=${crew.id}
    `;
  }

  async delete(crew: Crew): Promise<void> {
    await client.queryArray`
      DELETE FROM crews
      WHERE id=${crew.id}
    `;
  }

  async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    const crew = new Crew(row.id, row.name, row.bounty);
    return crew;
  }
}

ドメインモデルは別のクラスとして分離されています。

export class Crew {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  isDanger(): boolean {
    return this.bounty >= 1_000_000_000;
  }
}

以下のように利用します。テストコードは こちら です。

  const crewMapper = new CrewMapper();
  const crew = new Crew(1, "Nami", BigInt(60_000_000));
  await crewMapper.insert(crew);
const crewMapper = new CrewMapper();
const crew = await crewMapper.find(1);
crew.name = "Usopp";
await crewMapper.update(crew);

では、ここまで紹介したそれぞれのパターンの特徴や使いどころについてまとめます。

  • Row Data Gateway

    • テーブルのデータをオブジェクト指向的に自然な形で扱うアプローチ

    • Domain Modelとの相性は△

  • Table Data Gateway

    • Table Moduleと使うのが一般的

    • Domain Modelとの相性は△

  • Active Record

    • 扱いやすい

    • ドメインロジックが簡単ならDomain Modelとの相性は〇

  • Data Mapper

    • ドメインロジックが複雑ならDomain Modelとの相性は〇

1つのアプリケーションに対して、それぞれのパターンを相互排他的に使わなければならないということはありませんが、あまりいろいろなパターンを混ぜると非常に複雑になるので注意しましょう。

また、O/Rマッパーはオープンソースで活発に開発されているので、自分で実装するよりはそれらのツールに頼ったほうが良いでしょう。

ただし、ツールに頼るとしてもこれらのパターンを知っておくのは良いことです。なぜなら、ツールはすべての問題を解決してくれるわけではないので、自分の状況にあった実装を判断できるようにしておくことはとても価値があるからです。

ふるまいの問題

O/Rマッピングについて話すときは構造的な側面に集中しがちですが、ふるまいについても注意しなければなりません。ふるまいの問題とはどのように様々なオブジェクトを読み込んで、どのようにそれらをデータベースに保存するかというものです。

例えば、同じデータを表現するオブジェクトを別々に2つ作成してしまう問題を考えてみます。それぞれのオブジェクトをメモリー上でデータを更新して、それぞれを保存すると、どちらか片方の更新内容が失われることになります。

このような事態はIdentity Mapping を利用することで阻止できます。Table Data Gatewayで利用している例を示します。片方のオブジェクトの更新内容が失われる問題を回避していることは テストコード で確認できます。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

type RecordSet = CrewsRow[];

export class CrewGateway {
  private identityMap: { [id: number]: CrewsRow } = {};

  async insert(id: number, name: string, bounty: bigint): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${id}, ${name}, ${bounty})
    `;
  }

  async update(id: number, name: string, bounty: bigint): Promise<void> {
    await client.queryArray`
      UPDATE crews
      SET name=${name}, bounty=${bounty}
      WHERE id=${id}
    `;
  }

  async find(id: number): Promise<RecordSet> {
    if (this.identityMap[id]) {
      return [this.identityMap[id]];
    }

    const { rows } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;

    if (rows.length) {
      const [row] = rows;
      this.identityMap[row.id] = row;
    }

    return rows;
  }
}

Identity Mappingはデータベースへのアクセス数を減らすことができますが、これは主要な目的ではないことに注意してください。

ここは個人的な見解になりますが、片方のオブジェクトの更新内容が失われるという問題にIdentity Mappingで対応するのは、サーバーサイドの実装においては避けたほうが良いと思います。このような問題が起きている状態では、オブジェクトの影響範囲が広すぎてコードの可読性が著しく低い状態になっていると思われるので、コードの設計を見直す方が建設的だと思います。

ふるまいの問題の別の例として、あるデータをオブジェクトとして読み込むときに、関連する複数のオブジェクトもデータベースから読み込むかどうかという問題があります。必要ないのに毎回関連するオブジェクトを読み込むのは無駄になります。

このようなケースではLazy Loadが利用できます。

コード例を示していきます。"crews" テーブルのほかに、以下のように "special_moves" テーブルもあると想定していただければと思います。船員たちの必殺技が保存されているテーブルです。

// special_moves

 id |      name       |  crew_id
----+-----------------+------------
  1 | Gum-Gum Pistol  | 1
  2 | Gum-Gum Bazooka | 1
  3 | Oni Giri        | 2
  4 | Tora Gari       | 2

Lazy Loadにより、必殺技の読み込みを遅延させます。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

interface SpecialMove {
  name: string;
}

export class Crew {
  private _specialMoves: string[] | null = null;

  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async addSpecialMove(name: string) {
    await client.queryArray`
      INSERT INTO special_moves (name, crew_id)
      VALUES (${name}, ${this.id})
    `;
  }

  async specialMoves(): Promise<string[]> {
    if (this._specialMoves) {
      return this._specialMoves;
    }

    const { rows } = await client.queryObject<SpecialMove>`
      SELECT name
      FROM special_moves
      WHERE crew_id = ${this.id}
    `;
    this._specialMoves = rows.map(({ name }) => name);

    return this._specialMoves;
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Crew(row.id, row.name, row.bounty);
  }
}

以下のように船員の取得と別のタイミングで必殺技を取得することになります。テストコードは こちら です。

const crew = await Crew.find(1);
crew.name;                       // => "Luffy"
await crew.specialMoves();       // => ["Gum-Gum Pistol", "Gum-Gum Bazooka"]

データの読み込み

"find(id)" や "findForCustomer(customer)" のようなメソッドをファインダーメソッドと呼びますが、これについて注意すべきことがあります。

Row Data Gatewayのような行をベースとしたパターンでは、ファインダーメソッドをクラスメソッドとして定義するのが自然に思えますが、これは避けたほうがよい場合もあります。その理由はService Stubによるテストのための置き換えができなくなるからです。ファインダーを別のクラスとして定義するとその問題が解決できます。

コード例では、Active Recordの実装とは別に "CrewFinder" というクラスを作成しています。 DBからデータを取得するのに加えて、 "PopularityVoteService" という外部サービスから人気投票のランキングを取得して "Crew" インスタンスを作成しています。

import { client } from "../postgres_client.ts";

export class Crew {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
    private _popularityRanking: string,
  ) {}

  get id(): number {
    return this._id;
  }

  get popularityRanking(): string {
    return this._popularityRanking;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }
}

export interface PopularityVoteService {
  getRanking: (id: number) => Promise<string>;
}

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class CrewFinder {
  constructor(
    private popularityVoteService: PopularityVoteService,
  ) {}

  async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }

    const ranking = await this.popularityVoteService.getRanking(row.id);

    return new Crew(
      row.id,
      row.name,
      row.bounty,
      ranking,
    );
  }
}

ユニットテストなどのときは外部サービスからランキングを取得する部分の実装をスタブします。すべてのテストコードを確認したい場合は こちら をご参照ください。

const PopularityVoteServiceStub: PopularityVoteService = class {
  static getRanking(_id: number): Promise<string> {
    return Promise.resolve("1st");
  }
};

const crewFinder = new CrewFinder(PopularityVoteServiceStub);
const crew = await crewFinder.find(1);

RSpec のようにモックやスタブが充実しているようなテストフレームワークや言語、プラットフォームもありますので、一概にこのパターンを採用しないといけないわけではありません。

データの読み込みについて、もう一つ注意したいのはパフォーマンスの問題です。

複数の行を取得したいときにはクエリを一回で済むようにしてください。一行ずつ取得するのはパフォーマンスに問題が出ることが多いです。

以下ではクエリが一回で済む "findManyInGoodWay()" と繰り返しクエリをする "findManyInBadWay()" を例として実装しています。テストコードは こちら です。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class CrewGateway {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  static async findManyInGoodWay(ids: number[]): Promise<CrewGateway[]> {
    const { rows } = await client.queryObject<CrewsRow>(`
      SELECT id, name, bounty
      FROM crews
      WHERE id IN (${ids.join(",")})
    `);
    const crewGateways = rows.map((row: CrewsRow) =>
      new CrewGateway(row.id, row.name, row.bounty)
    );
    return crewGateways;
  }

  static findManyInBadWay(ids: number[]): Promise<CrewGateway[]> {
    return Promise.all(ids.map((id: number) => this.find(id)));
  }

  static async find(id: number): Promise<CrewGateway> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new CrewGateway(row.id, row.name, row.bounty);
  }
}

ただし、複数行を取得するときに悲観ロックを用いる場合は、それはそれでアプリケーションのパフォーマンスに問題を起こす可能性がありますので注意してください。

また、関連するオブジェクトを読み込む場合には "JOIN" を積極的に用いてください。一気に読み込む必要がない場合は、Lazy Loadを利用すると良いです。

"JOIN" を利用したコード例は以下のようになります。テストコードは こちら です。

import { client } from "../postgres_client.ts";

interface Row {
  id: number;
  name: string;
  bounty: bigint;
  special_move: string;
}

export class Crew {
  private _specialMoves: string[] = [];

  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  get specialMoves(): string[] {
    return this._specialMoves;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async addSpecialMove(name: string) {
    await client.queryArray`
      INSERT INTO special_moves (name, crew_id)
      VALUES (${name}, ${this.id})
    `;
  }

  static async findMany(ids: number[]): Promise<Crew[]> {
    const { rows } = await client.queryObject<Row>(`
      SELECT
        crews.id AS id,
        crews.name AS name,
        bounty,
        special_moves.name AS special_move
      FROM crews
      JOIN special_moves ON special_moves.crew_id = crews.id
      WHERE crews.id IN (${ids.join(",")})
    `);
    const crewsMap = rows.reduce((map: { [id: number]: Crew }, row: Row) => {
      if (map[row.id]) {
        const crew = map[row.id];
        crew._specialMoves = [...crew.specialMoves, row.special_move];
        return map;
      }

      const crew = new Crew(row.id, row.name, row.bounty);
      crew._specialMoves = [row.special_move];
      map[row.id] = crew;
      return map;
    }, {});
    return Object.values(crewsMap);
  }
}

ちょっとコードが複雑になっていますが、以下のようにイメージしてもらうと良いと思います。

// JOINするとこんな感じのデータが取得できるので
[
  { id: 1, name: "Luffy", bounty: BigInt(1_500_000_000), special_move: "Gum-Gum Pistol" },
  { id: 1, name: "Luffy", bounty: BigInt(1_500_000_000), special_move: "Gum-Gum Bazooka" },
  { id: 2, name: "Zoro", bounty: BigInt(320_000_000), special_move: "Oni Giri" },
  { id: 2, name: "Zoro", bounty: BigInt(320_000_000), special_move: "Tora Gari" },
]

// special_moveを配列にまとめる処理をしています
[
  { id: 1, name: "Luffy", bounty: BigInt(1_500_000_000), specialMoves: ["Gum-Gum Pistol", "Gum-Gum Bazooka"] },
  { id: 2, name: "Zoro", bounty: BigInt(320_000_000), specialMoves: ["Oni Giri", "Tora Gari"] },
]

ただし、これらのアドバイスも状況に応じて適用できるかどうかは変わりますので、自分の状況を見極めて実装をしてください。

関係性のマッピング

オブジェクトとリレーショナルデータベースにはインピーダンスミスマッチが存在します。そう、 インピーダンスミスマッチ が存在します。

特にデータの関係付けのやり方に大きな違いがあります。これには大きな問題が2つあります。

  • それぞれデータの関係を表現する方法がまったく違います。オブジェクトは別のオブジェクトへの参照を保持することでデータの関係を表現します。一方でリレーショナルデータベースでは別のテーブルへのキーを保持することでデータの関係を表現します。

  • オブジェクトはデータのコレクションを簡単に扱えますが、リレーショナルデータベースの正規化は単一の値で関連を扱うことを強制します。

これらの問題を適切に扱うには、オブジェクトにIdentify Fieldを持たせるようにしてリレーショナルデータベースの関係を保持します。外部キーがあるならForeign Key Mappingを使います。

改めて、以下のようなテーブルが存在することを想定してください。

// crews

 id | name  |   bounty
----+-------+------------
  1 | Luffy | 1500000000
  2 | Zoro  |  320000000

// special_moves

 id |      name       |  crew_id
----+-----------------+------------
  1 | Gum-Gum Pistol  | 1
  2 | Gum-Gum Bazooka | 1
  3 | Oni Giri        | 2
  4 | Tora Gari       | 2

ER図は以下のようになります。

ER図(crews,special_moves)

Foreign Key Mappingのコード例です。 "SpecialMove" は "crewId" という外部キーを保持しつつ、 "Crew" は "specialMoves" という配列のフィールド(正確にはゲッター)で関係性を表現しています。テストコードは こちら です。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class Crew {
  private _specialMoves: SpecialMove[] = [];

  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  get specialMoves(): SpecialMove[] {
    return this._specialMoves;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    const crew = new Crew(row.id, row.name, row.bounty);
    crew._specialMoves = await SpecialMove.findForCrew(crew.id);
    return crew;
  }
}

interface SpecialMovesRow {
  id: number;
  name: string;
  crew_id: number;
}

export class SpecialMove {
  private _id = 0;

  constructor(
    public name: string,
    public crewId: number,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert() {
    await client.queryArray`
      INSERT INTO special_moves (name, crew_id)
      VALUES (${this.name}, ${this.crewId})
    `;
  }

  static async findForCrew(crewId: number): Promise<SpecialMove[]> {
    const { rows } = await client.queryObject<SpecialMovesRow>`
      SELECT id, name, crew_id
      FROM special_moves
      WHERE crew_id = ${crewId}
    `;
    const specialMoves = rows.map((row: SpecialMovesRow) =>
      new SpecialMove(row.name, row.crew_id)
    );
    return specialMoves;
  }
}

あるテーブルが一つの特定のテーブルからしか参照されていないようなら、Dependent Mappingという手法も使えます。

Dependent Mappingのコード例では、Foreign Key Mappingと異なり、 "SpecialMove" クラスを定義していません。これにより、 "Crew" クラス以外は "special_moves" テーブルを意識する必要がなくなります。テストコードは こちら です。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

interface SpecialMove {
  name: string;
}

export class Crew {
  private _specialMoves: string[] = [];

  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  get specialMoves(): string[] {
    return this._specialMoves;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async addSpecialMove(name: string) {
    await client.queryArray`
      INSERT INTO special_moves (name, crew_id)
      VALUES (${name}, ${this.id})
    `;
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    const crew = new Crew(row.id, row.name, row.bounty);
    crew._specialMoves = await this.findSpecialMoves(crew.id);
    return crew;
  }

  private static async findSpecialMoves(crewId: number): Promise<string[]> {
    const { rows } = await client.queryObject<SpecialMove>`
      SELECT name
      FROM special_moves
      WHERE crew_id = ${crewId}
    `;
    return rows.map(({ name }) => name);
  }
}

多対多の関係についてはAssociation Table Mappingを用いることができます。

まず、以下のようにテーブルが存在していると想定してください。

// crews

 id | name  |   bounty
----+-------+------------
  1 | Luffy | 1500000000
  2 | Sanji |  330000000

// haki_list

 id |     name     
----+--------------
  1 | Sovereign
  2 | Armament
  3 | Observation

// crews_haki_list

 crew_id | haki_id 
---------+--------------
  1      | 1
  1      | 2
  1      | 3
  2      | 2
  2      | 3

ER図は以下のようになります。

ER図(crews,haki_list)

中間テーブル "crews_haki_list" を用いることで、どの船員がどの覇気を習得しているかを表現しています。

import { client } from "../postgres_client.ts";

interface HakiListRow {
  id: number;
  name: string;
}

export class Haki {
  constructor(
    private _id: number,
    public name: string,
  ) {}

  get id(): number {
    return this._id;
  }

  static async insert(name: string): Promise<void> {
    await client.queryArray`
      INSERT INTO haki_list (name)
      VALUES (${name})
    `;
  }

  static async findByName(name: string): Promise<Haki> {
    const { rows: [row] } = await client.queryObject<HakiListRow>`
      SELECT id, name
      FROM haki_list
      WHERE name = ${name}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Haki(row.id, row.name);
  }

  static async findForCrew(crew_id: number): Promise<Haki[]> {
    const { rows } = await client.queryObject<HakiListRow>`
      SELECT haki_id AS id, name
      FROM crews_haki_list
      JOIN haki_list ON haki_list.id = crews_haki_list.haki_id
      WHERE crew_id = ${crew_id}
    `;
    const hakiList = rows.map(({ id, name }: HakiListRow) =>
      new Haki(id, name)
    );
    return hakiList;
  }
}

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

export class Crew {
  private _hakiList: Haki[] = [];

  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
  ) {}

  get id(): number {
    return this._id;
  }

  get hakiList(): Haki[] {
    return this._hakiList;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
  }

  async addHaki(haki: Haki) {
    await client.queryArray`
      INSERT INTO crews_haki_list (crew_id, haki_id)
      VALUES (${this.id}, ${haki.id})
    `;
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    const crew = new Crew(row.id, row.name, row.bounty);
    crew._hakiList = await Haki.findForCrew(crew.id);
    return crew;
  }
}

以下のように船員の習得している覇気を確認できます。テストコードは こちら です。

 const luffy = await Crew.find(1);
 luffy.hakiList[0].name // => "Sovereign"
 luffy.hakiList[1].name // => "Armament"
 luffy.hakiList[2].name // => "Observation"

Value Objectsが小さなデータである場合は、わざわざ専用のテーブルを用意しないほうが良いです。例えば日付の範囲やお金を表すオブジェクトなどがValue Objectsの例です。この場合はそれぞれのフィールドを素直に分解して、関係するオブジェクトのテーブルに保存します。

例えば、賞金額に通貨の概念を導入するために "crews" テーブルに "currency" カラムが追加されていると想定します。

// crews

 id | name  |   bounty   | currency
----+-------+------------+----------
  1 | Luffy | 1500000000 | Berry
  2 | Sanji |  330000000 | Berry

Value Objectsである "Money" クラスを定義します。 "INSERT" するときには "Money" クラスのフィールドをそれぞれ分解してテーブルに保存するようにしています。テストコードは こちら です。

import { client } from "../postgres_client.ts";

export class Money {
  constructor(
    public amount: bigint,
    public currency: string,
  ) {}

  equalsTo(money: Money) {
    return this.amount === money.amount && this.currency === money.currency;
  }
}

interface CrewsRow {
  id: number;
  name: string;
  bounty_amount: bigint;
  bounty_currency: string;
}

export class CrewGateway {
  constructor(
    private _id: number,
    public name: string,
    public bounty: Money,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty_amount, bounty_currency)
      VALUES (${this.id}, ${this.name}, ${this.bounty.amount}, ${this.bounty.currency})
    `;
  }

  static async find(id: number): Promise<CrewGateway> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT
        id,
        name,
        bounty_amount,
        bounty_currency
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    const bounty = new Money(row.bounty_amount, row.bounty_currency);
    return new CrewGateway(row.id, row.name, bounty);
  }
}

非常に大きなデータを一つのカラムにSerialized LOBとして保存するという手法もあります。Serialized LOBではバイナリーやテキストとしてデータを保存しますが、近年ではJSONのほうがなじみがあるかと思われます。

JSONでデータを扱う例を示していきます。テーブルには Wikipedia のデータを保存するためのカラムが追加されているとします。

// crews

id | name |  bounty  |                              wikipedia
----+------+----------+--------------------------------------------------------------------
  1 | Nami | 60000000 | {"Reference":"https://en.wikipedia.org/wiki/Nami_(One_Piece)",...}

JSONデータを扱うRow Data Gatewayの例です。Wikipediaのデータは他のテーブルから参照することもないという想定でJSONで保存するようにします。

import { client } from "../postgres_client.ts";

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
  wikipedia: Record<string, unknown>;
}

export class CrewGateway {
  constructor(
    private _id: number,
    public name: string,
    public bounty: bigint,
    public wikipedia: Record<string, unknown>,
  ) {}

  get id(): number {
    return this._id;
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO crews (id, name, bounty, wikipedia)
      VALUES (${this.id}, ${this.name}, ${this.bounty}, ${this.wikipedia})
    `;
  }

  static async find(id: number): Promise<CrewGateway> {
    const { rows: [row] } = await client.queryObject<CrewsRow>`
      SELECT
        id,
        name,
        bounty,
        wikipedia
      FROM crews
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new CrewGateway(row.id, row.name, row.bounty, row.wikipedia);
  }
}

以下のようにwikipediaのデータにアクセスできます。テストコードは こちら です。

const data = await CrewGateway.find(1);
data.wikipedia["Reference"] // => "https://en.wikipedia.org/wiki/Nami_(One_Piece)"

他のテーブルと関係がない独立したデータであれば、Serialized LOBで保存することを検討してもよいと思います。

継承

上記で紹介したような階層表現のほかに、継承による階層表現もリレーショナルデータベースにとっては頭痛の種となります。

継承に関するテーブルの構成方法には3つの選択肢があります。

  • Class Table Inheritance

    • 1クラスごとに1テーブル

  • Concrete Table Inheritance

    • 具象クラスごとに1テーブル

  • Single Table Inheritance

    • すべてのクラスに対して1テーブル

これらのパターンはデータの重複とデータを取得するスピードの間でトレードオフがあります。

Class Table Inheritanceはテーブルとクラスの関係性がもっともシンプルです。ただし、複数の "JOIN" が必要になるのでパフォーマンスに懸念があります。

コード例を示していきます。以下のように抽象クラスも含めた3つのテーブルを用意します。

// people

 id |  name
----+--------
  1 | Jimbei
  2 | Smoker

// pirates

 person_id |   role
-----------+----------
     1     | Helmsman

// marines

 person_id |     rank
-----------+--------------
     2     | Vice Admiral

"Pirate" クラスや "Marine" クラスの "insert()" では "people" テーブルに対してもレコードを挿入していたり、 "find()" では "people" テーブルを "JOIN" して "name" フィールドを取得しています。テストコードは こちら です。

import { client } from "../postgres_client.ts";

abstract class Person {
  constructor(
    protected _id: number,
    public name: string,
  ) {}

  get id(): number {
    return this._id;
  }

  protected async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO people (id, name)
      VALUES (${this.id}, ${this.name})
    `;
  }
}

interface PiratesRow {
  id: number;
  name: string;
  role: string;
}

export class Pirate extends Person {
  constructor(
    protected _id: number,
    public name: string,
    public role: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await super.insert();
    await client.queryArray`
      INSERT INTO pirates (person_id, role)
      VALUES (${this.id}, ${this.role})
    `;
  }

  static async find(id: number): Promise<Pirate> {
    const { rows: [row] } = await client.queryObject<PiratesRow>`
      SELECT id, name, role
      FROM people
      INNER JOIN pirates
        ON person_id = id
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Pirate(row.id, row.name, row.role);
  }
}

interface MarinesRow {
  id: number;
  name: string;
  rank: string;
}

export class Marine extends Person {
  constructor(
    protected _id: number,
    public name: string,
    public rank: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await super.insert();
    await client.queryArray`
      INSERT INTO marines (person_id, rank)
      VALUES (${this.id}, ${this.rank})
    `;
  }

  static async find(id: number): Promise<Marine> {
    const { rows: [row] } = await client.queryObject<MarinesRow>`
      SELECT id, name, rank
      FROM people
      INNER JOIN marines
        ON person_id = id
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Marine(row.id, row.name, row.rank);
  }
}

Concrete Table Inheritanceは "JOIN" する必要がなく、1つのテーブルから1つのオブジェクトを取得できます。ただし、データが重複してしまうため、変更に手間がかかりやすいという欠点があります。特に基底クラスの変更はすべてのテーブルに影響しますし、階層自体の変更はさらに大きな影響があります。また、状況によってはオブジェクトのIDの管理がとてもややこしくなる面も持ち合わせています。

コード例を示していきます。以下のように具象クラスに対応する2つのテーブルを用意します。

// pirates

 id |  name  |   role
----+--------+----------
  1 | Jimbei | Helmsman

// marines

 id |  name  |    rank
----+--------+--------------
  1 | Smoker | Vice Admiral

各クラスのコードは以下のようになります。テストコードは こちら です。 "Pirate" クラスと "Marine" クラスはそれぞれ対応するテーブルからデータを取得するだけで済みます。これは一見するとすっきりしているようですが、例えば「 "name" カラムの文字数制限を変更する場合は、すべてのテーブルに変更を適用しないといけない」といったような気を付けなければならないこともあります。

import { client } from "../postgres_client.ts";

abstract class Person {
  constructor(
    protected _id: number,
    public name: string,
  ) {}

  get id(): number {
    return this._id;
  }
}

interface PiratesRow {
  id: number;
  name: string;
  role: string;
}

export class Pirate extends Person {
  constructor(
    protected _id: number,
    public name: string,
    public role: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO pirates (id, name, role)
      VALUES (${this.id}, ${this.name}, ${this.role})
    `;
  }

  static async find(id: number): Promise<Pirate> {
    const { rows: [row] } = await client.queryObject<PiratesRow>`
      SELECT id, name, role
      FROM pirates
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Pirate(row.id, row.name, row.role);
  }
}

interface MarinesRow {
  id: number;
  name: string;
  rank: string;
}

export class Marine extends Person {
  constructor(
    protected _id: number,
    public name: string,
    public rank: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO marines (id, name, rank)
      VALUES (${this.id}, ${this.name}, ${this.rank})
    `;
  }

  static async find(id: number): Promise<Marine> {
    const { rows: [row] } = await client.queryObject<MarinesRow>`
      SELECT id, name, rank
      FROM marines
      WHERE id = ${id}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    return new Marine(row.id, row.name, row.rank);
  }
}

Single Table Inheritanceは "JOIN" を避けることもできるし、テーブルの変更も容易です。ただし、テーブル空間に無駄が発生します。ある具象クラスでは必要なフィールドでも、ほかの具象クラスには必要ないフィールドが多数出てきます。

コード例を示していきます。以下のようにすべてのデータを含む1つのテーブルを用意します。

// people

 id |  name  |  type  |   role   |     rank
----+--------+--------+----------+--------------
  1 | Jimbei | pirate | Helmsman |
  2 | Smoker | marine |          | Vice Admiral

各クラスのコードは以下のようになります。テストコードは こちら です。"Pirate" クラスや "Marine" クラスそれぞれで必須となるフィールドが異なるので、コードレベルでのバリデーションに気をつかっているのがわかると思います。

import { client } from "../postgres_client.ts";

interface PeopleRow {
  id: number;
  name: string;
  role: string | null;
  rank: string | null;
}

abstract class Person {
  protected static type = "";

  constructor(
    protected _id: number,
    public name: string,
  ) {}

  get id(): number {
    return this._id;
  }
}

export class Pirate extends Person {
  protected static type = "pirate";

  constructor(
    protected _id: number,
    public name: string,
    public role: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO people (id, name, type, role)
      VALUES (${this.id}, ${this.name}, ${Pirate.type}, ${this.role})
    `;
  }

  static async find(id: number): Promise<Pirate> {
    const { rows: [row] } = await client.queryObject<PeopleRow>`
      SELECT id, name, role
      FROM people
      WHERE id = ${id} AND type = ${Pirate.type}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    if (!row.role) {
      throw new Error('Pirate must have "role"');
    }
    return new Pirate(row.id, row.name, row.role);
  }
}

export class Marine extends Person {
  protected static type = "marine";

  constructor(
    protected _id: number,
    public name: string,
    public rank: string,
  ) {
    super(_id, name);
  }

  async insert(): Promise<void> {
    await client.queryArray`
      INSERT INTO people (id, name, type, rank)
      VALUES (${this.id}, ${this.name}, ${Marine.type}, ${this.rank})
    `;
  }

  static async find(id: number): Promise<Marine> {
    const { rows: [row] } = await client.queryObject<PeopleRow>`
      SELECT id, name, rank
      FROM people
      WHERE id = ${id} AND type = ${Marine.type}
    `;
    if (!row) {
      throw new Error("Record Not Found");
    }
    if (!row.rank) {
      throw new Error('Marine must have "rank"');
    }
    return new Marine(row.id, row.name, row.rank);
  }
}

テーブル空間の無駄については多くのデータベースは圧縮をうまいことしてくれるので心配になることは少ないかもしれません。

これら3つのパターンは状況によって使い分けます。最初の選択としてオススメなのはSingle Table Inheritanceです。理由は導入が簡単で、リファクタリングもしやすいからです。余計なカラムが存在することによる問題が無視できない場合は、他の2つのパターンを検討するとよいです。

なお、PostgreSQLには継承の機能が存在する ようですが、自分はまったく詳しくないので解説は控えさせていただきます。

メタデータの利用

たくさんのクラスを実装していると、シンプルな繰り返しの作業になりがちです。そのようなときには、Metadata Mappingを使うよいです。これはコード生成かメタプログラミングで実装します。

コード例を示していきますが、今回はなんちゃってメタプログラミングで実装してみています。今までの例にも増して、実用には耐えられないコードになっていますので、ご注意ください。

ちなみに型のある言語の場合はコード生成で実装すると思いますので、本格的に知りたい方は Prismaのコード生成 など参考にしてみると良いかもしれません。

以下のようなクラスとテーブルの対応を表す設定ファイルがJSONで書かれているとします。

{
  "Crew": {
    "table": "crews",
    "fields": [
      { "name": "name", "column": "name" },
      { "name": "bounty", "column": "bounty" }
    ]
  },
  "SpecialMove": {
    "table": "special_moves",
    "fields": [
      { "name": "name", "column": "name" },
      { "name": "crewId", "column": "crew_id" }
    ]
  }
}

このような設定ファイルを用いて、 "BaseModel" に共通の処理を書いておくと、 "Crew" や "SpecialMove" クラスの定義は設定ファイルから値を読み込むだけで済むようになります。 "BaseModel" の実装はかなりごちゃごちゃしている上にコンパイルを頑張って通した程度のクオリティなので、「なんか大変そうだなー」くらいの感覚で読み飛ばしてください。

import { client } from "../postgres_client.ts";
import { dirname, fromFileUrl } from "../../deps.ts";

interface ConfigField {
  name: string;
  column: string;
}

abstract class BaseModel {
  protected static table: string;
  protected static fields: ConfigField[];

  protected static idField = { name: "id", column: "id" };

  static get allFields(): ConfigField[] {
    return [this.idField, ...this.fields];
  }

  [index: string]: unknown;

  constructor(protected id: number, ...values: unknown[]) {
    const self = <typeof BaseModel> this.constructor;
    self.fields.forEach((field, index) => {
      this[field.name] = values[index];
    });
  }

  async insert(): Promise<void> {
    const self = <typeof BaseModel> this.constructor;
    const columnNames = self.allFields.map(({ column }) => column);
    const placeholders = self.allFields.map((_v, i) => `$${i + 1}`);
    const values = self.allFields.map(({ name }) => this[name]);

    await client.queryArray(
      `
        INSERT INTO ${self.table} (${columnNames.join(",")})
        VALUES (${placeholders.join(",")})
      `,
      values,
    );
  }

  static async find(id: unknown): Promise<BaseModel> {
    const columnNames = this.allFields.map(({ column }) => column);
    const { rows: [row] } = await client.queryArray(`
      SELECT ${columnNames.join(",")}
      FROM ${this.table}
      WHERE ${this.idField.column} = ${id}
    `);
    if (!row) {
      throw new Error("Record Not Found");
    }
    // @ts-ignore: I don't know how to specify concrete class except "this"
    return new this(...row);
  }
}

const __dirname = dirname(fromFileUrl(import.meta.url));
const configJson = await Deno.readTextFile(`${__dirname}/config.json`);
const config = JSON.parse(configJson);

export class Crew extends BaseModel {
  protected static table = config.Crew.table;
  protected static fields: ConfigField[] = config.Crew.fields;
}

export class SpecialMove extends BaseModel {
  protected static table = config.SpecialMove.table;
  protected static fields: ConfigField[] = config.SpecialMove.fields;
}

これで "Crew" クラスや "SpecialMove" クラスで "insert()" や "find()" が利用できます。テストコードは こちら です。

const crew = await Crew.find(1);
crew.name // => "Luffy"

const specialMove = await SpecialMove.find(1);
specialMove.name // => "Gum-Gum Pistol"

Metadata Mappingを利用する場合は、Query Objectを使うとオブジェクトの文脈でSQLを組み立てやすくなります。Query Objectはオブジェクトの表現をテーブル表現に翻訳するときに、Metadata Mappingを利用します。

以下のように定義される "Criteria" と "QueryObject" を用いて、SQLの "WHERE" で指定する条件を組み立てられるようにしています。繰り返しますが、実用には耐えられないコードですのでご注意ください。

import { client } from "../postgres_client.ts";
import { dirname, fromFileUrl } from "../../deps.ts";

interface ConfigField {
  name: string;
  column: string;
}

export class Criteria {
  private constructor(
    private sqlOperator: string,
    protected fieldName: string,
    protected value: unknown,
  ) {}

  static in(fieldName: string, value: unknown[]): Criteria {
    return new Criteria("IN", fieldName, value);
  }

  generateSql(fields: ConfigField[]): string {
    const field = fields.find(({ name }) => name === this.fieldName);
    if (!field) {
      throw new Error(`Field Not Found: ${this.fieldName}`);
    }
    const rightSide = Array.isArray(this.value)
      ? `(${this.value.join(",")})`
      : this.value;
    return `${field.column} ${this.sqlOperator} ${rightSide}`;
  }
}

export class QueryObject {
  private criteria: Criteria[] = [];

  constructor(
    private klass: typeof BaseModel,
  ) {}

  addCriteria(criteria: Criteria): void {
    this.criteria.push(criteria);
  }

  generateWhereClause(): string {
    const conditions = this.criteria.map((c) =>
      c.generateSql(this.klass.allFields)
    );
    return conditions.join(" AND ");
  }
}

abstract class BaseModel {
  ... // findBy()以外は直前のコード例と同様です

  static async findBy(whereClause: string): Promise<BaseModel[]> {
    const columnNames = this.allFields.map(({ column }) => column);
    const { rows } = await client.queryArray(`
      SELECT ${columnNames.join(",")}
      FROM ${this.table}
      WHERE ${whereClause}
    `);
    const instances = rows.map((row) => {
      // @ts-ignore: I don't know how to specify concrete class except "this"
      return new this(...row);
    });
    return instances;
  }
}

const __dirname = dirname(fromFileUrl(import.meta.url));
const configJson = await Deno.readTextFile(`${__dirname}/config.json`);
const config = JSON.parse(configJson);

export class Crew extends BaseModel {
  protected static table = config.Crew.table;
  protected static fields: ConfigField[] = config.Crew.fields;
}

export class SpecialMove extends BaseModel {
  protected static table = config.SpecialMove.table;
  protected static fields: ConfigField[] = config.SpecialMove.fields;
}

どのように利用するのかを テストコード の一部を抜粋して紹介します。QueryObjectで組み立てた "WHERE" を "findBy()" に渡して条件に合致する "Crew" を取得しています。

const idCriteria = Criteria.in("id", [1]);
const crewQueryObject = new QueryObject(Crew);
crewQueryObject.addCriteria(idCriteria);
const crewWhereClause = crewQueryObject.generateWhereClause();
const [crew] = await Crew.findBy(crewWhereClause);

crew.name // => "Luffy"

最後にRepositoryパターンを紹介します。これはData Mapperからファインダーメソッドを分離するもので、ドメインレイヤーなどからQuery Objectを直接利用するのを避けるのに役に立ちます。これによってドメインロジックからデータベースの知識を分離することが可能です。

先ほどのコード例ではActive Recordを実装していましたが、今回はData Mapperを実装しています。加えて、Data Mapperではなく "CrewRepository" に "findBy()" の実装をしています。Query Objectを利用しつつ、頑張ってDomain Modelを作っています。

import { client } from "../postgres_client.ts";
import { dirname, fromFileUrl } from "../../deps.ts";

import { Crew, SpecialMove } from "./domain.ts";

interface ConfigField {
  name: string;
  column: string;
}

export class Criteria {
  ... // 直前のコード例と同様です
}

class QueryObject {
  ... // 直前のコード例と同様です
}

abstract class BaseMapper {
  protected static _table: string;
  protected static fields: ConfigField[];

  protected static idField = { name: "id", column: "id" };

  static get table(): string {
    return this._table;
  }

  static get allFields(): ConfigField[] {
    return [this.idField, ...this.fields];
  }

  async insert(...values: unknown[]): Promise<void> {
    const self = <typeof BaseMapper> this.constructor;
    const columnNames = self.allFields.map(({ column }) => column);
    const placeholders = self.allFields.map((_v, i) => `$${i + 1}`);

    await client.queryArray(
      `
        INSERT INTO ${self.table} (${columnNames.join(",")})
        VALUES (${placeholders.join(",")})
      `,
      values,
    );
  }
}

const __dirname = dirname(fromFileUrl(import.meta.url));
const configJson = await Deno.readTextFile(`${__dirname}/config.json`);
const config = JSON.parse(configJson);

export class CrewMapper extends BaseMapper {
  protected static _table = config.Crew.table;
  protected static fields: ConfigField[] = config.Crew.fields;

  insert(crew: Crew): Promise<void> {
    return super.insert(crew.id, crew.name, crew.bounty);
  }
}

export class SpecialMoveMapper extends BaseMapper {
  protected static _table = config.SpecialMove.table;
  protected static fields: ConfigField[] = config.SpecialMove.fields;

  insert(specialMove: SpecialMove): Promise<void> {
    return super.insert(specialMove.id, specialMove.name, specialMove.crewId);
  }
}

export class CrewRepository {
  async findBy(criteria: Criteria[]): Promise<Crew[]> {
    const crewQueryObject = new QueryObject(CrewMapper);
    for (const c of criteria) {
      crewQueryObject.addCriteria(c);
    }
    const whereClause = crewQueryObject.generateWhereClause();

    const crewColumnNames = CrewMapper.allFields.map(({ column }) => column);
    const { rows: crewRows } = await client.queryArray<
      [number, string, bigint]
    >(`
      SELECT ${crewColumnNames.join(",")}
      FROM ${CrewMapper.table}
      WHERE ${whereClause}
    `);
    if (!crewRows.length) {
      return [];
    }

    const specialMoveColumnNames = SpecialMoveMapper.allFields.map((
      { column },
    ) => column);
    const specialMoveCrewIdField = SpecialMoveMapper.allFields.find((
      { name },
    ) => name === "crewId");
    if (!specialMoveCrewIdField) {
      throw new Error("Field Not Found: crewId");
    }
    const crewIds = crewRows.map(([id]) => id);
    const { rows: specialMoveRows } = await client.queryArray<
      [number, string, number]
    >(`
      SELECT ${specialMoveColumnNames.join(",")}
      FROM ${SpecialMoveMapper.table}
      WHERE ${specialMoveCrewIdField.column} IN (${crewIds.join(",")})
    `);
    const crews = crewRows.map(([crewId, crewName, bounty]) => {
      const crewSpecialMoveRows = specialMoveRows.filter(([_id, _name, cId]) =>
        cId === crewId
      );
      const specialMoveNames = crewSpecialMoveRows.map(([_id, name]) => name);
      return new Crew(crewId, crewName, bounty, specialMoveNames);
    });
    return crews;
  }
}

利用側ではQueryObjectを使わないので、SQLの存在を知らなくて済むようになっています。実際のテストコードは こちら です。

const crewRepository = new CrewRepository();
const idCriteria = Criteria.in("id", [1]);
const [crew] = await crewRepository.findBy([idCriteria]);

crew.name // => "Luffy"
crew.specialMoves // => ["Gum-Gum Pistol", "Gum-Gum Bazooka"]

データベースコネクション

最後にデータベースコネクションについての話です。といってもO/Rマッパーを自分で実装する機会はあまり多くないと思いますので、あまりデータベースコネクションについて細かく気にしたことがない人もいるのではないかと思います。(自分はまさにそういう人間でした。)

コマンドを実行する前には必ずデータベースとのコネクションを開く必要があります。コマンドを実行する前にコネクションを開き、終了したら閉じるということをしていると処理のコストが大きいので、大抵はコネクションプールを使います。

deno-postgres でコネクションプールの動作を確認してみます。

import { Client, Pool } from "../../deps.ts";

function sleep(seconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

const connectionParams = {
  user: "postgres",
  password: "password",
  database: "postgres",
  hostname: "db",
  port: 5432,
};

export async function tryDbConnections() {
  const client = new Client(connectionParams);
  const pool = new Pool(connectionParams, 1);
  await sleep(1);

  for (let i = 0; i < 3; i++) {
    await client.connect();
    await client.end();

    const poolClient = await pool.connect();
    poolClient.release();
  }

  await pool.end();
}

これを実行すると、PostgreSQLのログに以下のようなものが出力されます(ちなみに、 "log_connections" という設定を "on" にしておく 必要があります)。通常のクライアントは3回コネクションを開いていますが、コネクションプールを用いると1回コネクションを開くだけで済んでいる様子がわかります。

# PostgreSQLのログ

# コネクションプールのインスタンスを作成しているタイミングでコネクションを開いている
2022-11-26 05:01:49.315 UTC [169] LOG:  connection received: host=172.18.0.3 port=40146
...
# 1秒間のスリープ後に通常のコネクションを3回開いている
2022-11-26 05:01:50.321 UTC [170] LOG:  connection received: host=172.18.0.3 port=40160
...
2022-11-26 05:01:50.348 UTC [171] LOG:  connection received: host=172.18.0.3 port=40164
...
2022-11-26 05:01:50.367 UTC [172] LOG:  connection received: host=172.18.0.3 port=40170
...

ここからはコネクションをいつ開いていつ閉じるかをどのように管理するかを考えます。トランザクションを利用する場合は、トランザクションに含めるすべてのコマンドを1つの特定のコネクションで実行しなければならないことを念頭に置いておく必要があります。

もっとも一般的なアドバイスは、「必要なときにコネクションを開いて、必要なくなったらちゃんと閉じる」というものです。これは2つの問題があります。

  • 必要な箇所で確実にコネクションを取得していることを保証しないといけない

  • コネクションの閉じ忘れが起きやすい

必要な箇所で確実にコネクションを取得していることを保証するためには、Registryパターンを使うことができます。ただし、マルチスレッドでアプリケーションを動かす場合はスレッドごとにRegistryを用意するようにしてください。

必要なタイミングで都度 "Registry" クラスからDBクライアントを呼び出せるので、コネクションの開閉を簡単に行うことができる様子を示します。テストコードは こちら です。

import { Client } from "../../deps.ts";

const connectionParams = {
  user: "postgres",
  password: "password",
  database: "postgres",
  hostname: "db",
  port: 5432,
};

class Registry {
  static _client = new Client(connectionParams);

  static get client() {
    return this._client;
  }
}

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

type RecordSet = CrewsRow[];

export class CrewGateway {
  async insert(id: number, name: string, bounty: bigint): Promise<void> {
    await Registry.client.connect();
    await Registry.client.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${id}, ${name}, ${bounty})
    `;
    await Registry.client.end();
  }

  async find(id: number): Promise<RecordSet> {
    await Registry.client.connect();
    const { rows } = await Registry.client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    await Registry.client.end();
    return rows;
  }
}

コネクションを閉じ忘れる問題についても考えていきます。また、閉じ忘れ以外に閉じるタイミングについても注意して考える必要があります。トランザクションを利用する場合は、途中でコネクションを閉じてしまうとそれまでの処理がロールバックされてしまうことに注意してください。

これらの問題から、コネクションはトランザクションごとに管理することをオススメします。また、データの読み込みのようなトランザクションを利用しない処理に対してもそれぞれのコネクションを開いてもよさそうです。短命のコネクションになってしまいますが、コネクションプールがその問題は解決します。

Webアプリケーションにおいては開きっぱなしにしているコネクションを使いまわすようにして、基本的にはコネクションを閉じないという戦略もよく採用されていそうです。ActiveRecord::ConnectionHandling の実装や使われ方を試しに調べてみても、そんな気がしました。間違ったことを言っていたらすみません。

ここではトランザクションが必要な "insert()" ではコネクションを逐一開いて、 "find()" では常時コネクションを開いているクライアントを利用するようにししたコード例を示します。テストコードは こちら です。

import { Client, Pool } from "../../deps.ts";

const connectionParams = {
  user: "postgres",
  password: "password",
  database: "postgres",
  hostname: "db",
  port: 5432,
};

const _pool = new Pool(connectionParams, 2);
const _client: Client = await _pool.connect();

class Registry {
  static get pool(): Pool {
    return _pool;
  }

  static get client(): Client {
    return _client;
  }
}

interface CrewsRow {
  id: number;
  name: string;
  bounty: bigint;
}

interface SpecialMoveRow {
  name: string;
}

export class Crew {
  constructor(
    public id: number,
    public name: string,
    public bounty: bigint,
    public specialMoves: string[],
  ) {}

  async insert(): Promise<void> {
    const client = await Registry.pool.connect();
    const transaction = await client.createTransaction("Crew.insert()");
    await transaction.begin();
    await transaction.queryArray`
      INSERT INTO crews (id, name, bounty)
      VALUES (${this.id}, ${this.name}, ${this.bounty})
    `;
    if (this.specialMoves.length) {
      const values = this.specialMoves.map((specialMove) =>
        `('${specialMove}', ${this.id})`
      );
      await transaction.queryArray(`
        INSERT INTO special_moves (name, crew_id)
        VALUES ${values.join(",")}
      `);
    }
    await transaction.commit();
    client.release();
  }

  static async find(id: number): Promise<Crew> {
    const { rows: [crewRow] } = await Registry.client.queryObject<CrewsRow>`
      SELECT id, name, bounty
      FROM crews
      WHERE id = ${id}
    `;
    if (!crewRow) {
      throw new Error("Record Not Found");
    }
    const { rows: specialMoveRows } = await Registry.client.queryObject<
      SpecialMoveRow
    >`
      SELECT name
      FROM special_moves
      WHERE crew_id = ${id}
    `;
    const specialMoves = specialMoveRows.map(({ name }) => name);
    return new Crew(id, crewRow.name, crewRow.bounty, specialMoves);
  }
}

コネクション管理の戦略も環境に左右されるものですので、それぞれの状況に応じて適切な戦略を選択していきましょう。

特に最近のWebアプリケーション開発では、スレッド処理以外にも非同期処理が用いられることもあるため、トランザクションごとにコネクションを開くテクニックは重要だと思います。

さいごに

今回はChapter 3. Mapping to Relational Databasesについての紹介をしました。

説明が不足していたり、わかりにくいようなところがありましたら、お気軽にご連絡いただければと思います。

次回はChapter 4. Web Presentationを紹介します。どうぞよろしくお願いします。

この記事が気に入ったらサポートをしてみませんか?