はじめに
はじめまして。エンジニアのDannyです。
今回は TypeORM
という O/Rマッパー
で、エンティティを定義する際のガイドラインについて書かせていただきます。
TypeORMとは
TypeScript製の O/Rマッパー
です。
弊社ではTyepScriptで実装しているサーバアプリケーションがいくつかあるのですが、その一部で採用しています。
TypeScriptで開発する際の O/Rマッパー
としてはデファクトになりつつあると思います。
Node.js/TypeScriptの O/Rマッパー
比較や、TypeORM
の使用感に関しては弊社suzukiの資料をご参照ください。
本記事では、最新バージョンである 0.2.7
を使用します。
コンストラクタの定義
さっそくですが、まずはコンストラクタについて考えてみます。
ここでは、MySQLで以下のようなテーブルがあると仮定して進めてみます。
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, コンストラクタで初期化すべきプロパティ
では、すべてのプロパティをコンストラクタで初期化すべきでしょうか。
よりミスが発生しにくい構成にするためにも、あくまでもインサート時に設定する必要があるプロパティのみを扱うのがベターでしょう。
つまり、初期値が決まっているプロパティは宣言時に初期化し、コンストラクタでは初期化しない、ということです。
こう言われると当たり前のように思えますが、意外に忘れられているケースを見てきました。
たとえば Book
の isPublished
は常に 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 = '';
}
}
このようにしても良いのですが、事情を知らない人が見たときに何のための条件分岐なのか分からず混乱してしまう可能性があるため、
初期化したいプロパティに対してそれぞれ引数を宣言する
というルールにしてしまうのがシンプルかつ安全で良いのではないかと思います。
各カラムの定義
ここからは、各カラムのライフサイクルや参照方法に応じて、どのようにプロパティを定義するかを考えてみます。
先ほどのテーブル構成を以下のように拡張したと仮定して読み進めてください。
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
のエンティティ定義に関して、現時点でのベストプラクティス(と思っているもの)について、何点かピックアップして書かせていただきました。
まだまだ模索中の部分ではありますが、少しでも皆様の参考になればうれしいです。
また、本記事でビットバンクに興味を持ってくださったエンジニアの皆様からのご応募をお待ちしています。