TypeORMでエンティティを定義する際のガイドライン

はじめに

はじめまして。エンジニアのDannyです。

今回は TypeORM という O/Rマッパー で、エンティティを定義する際のガイドラインについて書かせていただきます。

TypeORMとは

TypeScript製の O/Rマッパー です。

弊社ではTyepScriptで実装しているサーバアプリケーションがいくつかあるのですが、その一部で採用しています。

TypeScriptで開発する際の O/Rマッパー としてはデファクトになりつつあると思います。

Node.js/TypeScriptの O/Rマッパー 比較や、TypeORM の使用感に関しては弊社suzukiの資料をご参照ください。

本記事では、最新バージョンである 0.2.7 を使用します。

コンストラクタの定義

さっそくですが、まずはコンストラクタについて考えてみます。

ここでは、MySQLで以下のようなテーブルがあると仮定して進めてみます。

er_1

1, コンストラクタの要否

最初の論点はコンストラクタの必要性についてです。まずは公式ドキュメントのサンプルコードを見てみましょう。

import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    isActive: boolean;

}

このコードにはコンストラクタが定義されていません。
ざっと見た感じでは、ほかのページのサンプルコードでもコンストラクタは定義されておらず、初期化後に値をアサインしています。
これを参考に、book テーブルのエンティティを定義してみると、以下のようになります。

import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";

@Entity()
export class Book {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column()
    auther: string;

    @Column()
    isPublished: boolean;

}

しかし、このコードでは各プロパティの初期化が行われていないため、このままだとコンパイラの警告が出てしまいます。

すべてのプロパティをオプショナルにしたり、! で値の存在をコンパイラに対して約束するなどして回避する方法もありますが、望ましい対応とは言えないでしょう。

オブジェクトの不変条件を満たすためにもコンストラクタは必須だと思います。

@Entity()
export class Book {

    @PrimaryGeneratedColumn()
    id?: number;

    @Column()
    title: string;

    @Column()
    auther: string;

    @Column()
    isPublished: boolean;

    constructor(title: string, auther: string, isPublished: boolean) {
        this.title = title;
        this.auther = auther;
        this.isPublished = isPublished;
    }
}

取り急ぎ、各プロパティをコンストラクタで初期化するようにしました。

また、id についてはデータベース側で値が決定されるため、オプショナルにしました。この点についてはのちほどあらためて触れます。

2, コンストラクタで初期化すべきプロパティ

では、すべてのプロパティをコンストラクタで初期化すべきでしょうか。

よりミスが発生しにくい構成にするためにも、あくまでもインサート時に設定する必要があるプロパティのみを扱うのがベターでしょう。

つまり、初期値が決まっているプロパティは宣言時に初期化し、コンストラクタでは初期化しない、ということです。

こう言われると当たり前のように思えますが、意外に忘れられているケースを見てきました。

たとえば BookisPublished は常に false でインサートし、何らかのバッチ処理で true に変更すべきものだとすると、以下のように定義できます。

@Entity(
export class Book {

    @PrimaryGeneratedColumn()
    id?: number;

    @Column()
    title: string;

    @Column()
    auther: string;

    @Column('tinyint')
    isPublished = false;

    constructor(title: string, auther: string) {
        this.title = title;
        this.auther = auther;
    }
}

まだ発展途上ですが、少しだけオブジェクトのライフサイクルがイメージしやすくなりました。

3, コンストラクタの引数にオブジェクトを使用するか

基本的にこの論点に関してはある程度決めの問題かと思っているのですが、TypeORMのエンティティ定義においては1つのプロパティに対して1つの引数を設けるべきだと思います。

というのも、TypeORMはデータベースからのロード時に、引数を一切渡さずにコンストラクタを呼びます。
そのため、オブジェクトを介して値を渡そうとすると、TypeErrorが発生してしまいます。

試しに以下のようにすると、

constructor(data: { title: string, auther: string }) {
    this.title = data.title;
    this.auther = data.auther;
}

実行時に以下のエラーが発生します。

TypeError: Cannot read property 'title' of undefined

      17 |
      18 |   constructor(data: { title: string, auther: string }) {
    > 19 |     this.title = data.title;
         |                       ^
      20 |     this.auther = data.auther;
      21 |   }
      22 | }

この件に関する詳細はこちらの Issue を参照してください。

これを防ぐにはコンストラクタを以下のように実装する必要があります。

constructor(data: { title: string, auther: string }) {
    if (data) {
        this.title = data.title;
        this.auther = data.auther;
    } else {
        this.title = '';
        this.auther = '';
    }
}

このようにしても良いのですが、事情を知らない人が見たときに何のための条件分岐なのか分からず混乱してしまう可能性があるため、

初期化したいプロパティに対してそれぞれ引数を宣言する

というルールにしてしまうのがシンプルかつ安全で良いのではないかと思います。

各カラムの定義

ここからは、各カラムのライフサイクルや参照方法に応じて、どのようにプロパティを定義するかを考えてみます。

先ほどのテーブル構成を以下のように拡張したと仮定して読み進めてください。

er_2

1, 変更されないカラム

インサートされた後に値が変更されないカラムに関しては、対応するプロパティにも readonly をつけて、値の変更を禁止しておきましょう。

@Column()
readonly title: string;

ちなみに、TypeORMの Column デコレータに渡すオプションにも値の変更を防ぐためのパラメータが存在するのですが、バグ が見つかっており値を変更できてしまいます。

修正の Pull Request も出ていますので、マージされるのを待ちましょう。

オプションを使用すると、定義は以下のようになります。

@Column({ readonly: true })
readonly title: string;

2, デフォルトNULLのカラム

TypeScriptを使用しているとオプショナルをよく使用するため、デフォルト値が NULL のカラムに対して、以下のようにプロパティを定義しているケースを何度か目にしました。

@Column({ default: null })
publishedAt: Date?;

コンパイルは問題なく通るのですが、TypeORMはデータベースに NULL が格納されている場合、対応するプロパティに対して null をセットするため、実行時に不一致が生じてしまいます。
そのため、上記のようなケースでは以下のように定義してあげるべきです。

@Column('datetime', { default: null })
publishedAt: Date | null = null;

ちなみに、TypeORMのトランスフォーム機能を利用して以下のように定義する手もあるんじゃないか、と思われるかもしれません。

@Column('datetime', {
  default: null,
  transformer: {
    from: v => v === null ? undefined : v,
    to: v => v === undefined ? null : v,
  }
})
publishedAt?: Date;

これは一見うまくいきそうに見えますが、実行時には null が格納されますのでご注意ください。

詳細はこちらの Issue をご参照ください。

3, データベース側で動的に値が決まるカラム

オートインクリメントされるIDや、挿入時点のタイムスタンプ等のカラムに関しては、オプショナルを使用することにしています。

これらはインサートされるまでアプリケーションからは値が分からないため、素直にオプショナルで定義しておくのが良いかと思いました。

また、これらもアプリケーションからは変更しない値になりますので、readonly をつけておくと良いでしょう。

@PrimaryGeneratedColumn()
readonly id?: number;
@CreateDateColumn()
readonly createdAt?: Date;

@UpdateDateColumn()
readonly updatedAt?: Date;

4, リレーションカラム

公式ドキュメントのサンプルコードを参考に Auther, Book のリレーションを表現しようとすると、以下のようになります。

// Auther
@OneToMany(type => Book, book => book.auther)
books: Book[];

// Book
@ManyToOne(type => Auther, auther => auther.books)
auther: Auther;

この状態ですと、参照対象のIDがわかれば足りるケースでもジョインを行ってしまうため、ベストな形ではないと思われます。
このようなケースでは以下のように定義することにしています。

// Auther
@OneToMany(type => Book, book => book.auther)
books?: Book[];

// Book
@Column()
readonly autherId: number;

@ManyToOne(type => Auther, auther => auther.books)
@JoinColumn({ name: 'autherId' })
readonly auther?: Auther;

このようにすると、Auther オブジェクトが必要な場合には明示的にジョインを行い、そうでない場合は autherId だけを使用できます。

5, 完成

import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Book } from './book.entity';

@Entity()
export class Auther {

  @PrimaryGeneratedColumn()
  readonly id?: number;

  @Column()
  readonly name: string;

  @CreateDateColumn()
  readonly createdAt?: Date;

  @UpdateDateColumn()
  readonly updatedAt?: Date;

  @OneToMany(type => Book, book => book.auther)
  books?: Book[];

  constructor(name: string) {
    this.name = name;
  }
}
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Auther } from './auther.entity';

@Entity()
export class Book {

  @PrimaryGeneratedColumn()
  readonly id?: number;

  @Column()
  readonly title: string;

  @Column()
  readonly autherId: number;

  @ManyToOne(type => Auther, auther => auther.books)
  @JoinColumn({ name: 'autherId' })
  readonly auther?: Auther;

  @Column('datetime', { default: null })
  publishedAt: Date | null = null;

  @CreateDateColumn()
  readonly createdAt?: Date;

  @UpdateDateColumn()
  readonly updatedAt?: Date;

  constructor(title: string, autherId: number) {
    this.title = title;
    this.autherId = autherId;
  }
}

終わりに

ここまで読んでいただき、ありがとうございました。

今回は TypeORM のエンティティ定義に関して、現時点でのベストプラクティス(と思っているもの)について、何点かピックアップして書かせていただきました。

まだまだ模索中の部分ではありますが、少しでも皆様の参考になればうれしいです。

また、本記事でビットバンクに興味を持ってくださったエンジニアの皆様からのご応募をお待ちしています。

Author image
About Danny
expand_less