オイオイオイ書くわアイツ

ほうクソブログですか……たいしたものですね

がんばれやってけ TypeORM

TypeScript アヨヨンヨアヨンヨー のやつ です。 アヨンヨーつくったひとに 「書いて〜😣」ていわれたので、がんばった。

qiita.com


概要

  • TypeORM は EventListner とかと一緒に使わないと結構厳しい
  • TypeScript の機能上仕方ない感もある

TypeORM とは

TypeScript で DB とよしなにやるための OR マッパーライブラリ。 デコレータを使って良い感じに DB の構造を指定してあげることができる。 例として、 OkWoman なる以下のクラスのインスタンスの情報を DB に保存することを考える。 名前、年齢、誕生日、そして ok というプロパティを持っている。 ok が true だと 🙆 で false だと 🙅 という気持ち。

export class OkWoman {
    name: string;
    age: number;
    birthday: Date;
    ok: boolean;
}

TypeORM ではデコレータを使うことで、 OkWoman のインスタンスを保存するテーブル名や、各プロパティを保存するカラム名、データの型などを指定してあげられる。 以下のようにする。

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

@Entity('ok_women') // テーブル名の指定
export class OkWoman {
    @PrimaryGeneratedColumn() // auto increment なカラムも指定できる
    id: number;

    @Column('varchar', { name: 'name' }) // カラムのデータ型とカラム名の指定
    name: string;

    @Column('int', { name: 'age' })
    age: number;

    @Column('date', { name: 'birthday' })
    birthday: Date;

    @Column('tinyint', { name: 'ok' })
    ok: boolean;
}

こういう感じで Entity を定義した後、 synchronize: true をオプションに加えて DB に接続するか、 runMigrationssynchronize 関数を使うと DB にテーブルが作成される。 DB への接続は下のようにする。

export const config: ConnectionOptions = {
    type: 'mysql',
    charset: 'utf8mb4',
    host: 'host',
    port: 3306,
    username: 'user',
    password: 'pass',
    database: 'database',
    entities: [ // このコネクションで用いる Entity を指定する。ここで指定したものを DB に保存したり DB から取り出したりできる。
        OkWoman
    ],
    synchronize: true,
    logging: 'all',
}

await createConnection(config);

作成されるテーブルは以下の通り。

+----------+--------------+------+-----+---------+----------------+
| Field    | Type         | Null | Key | Default | Extra          |
+----------+--------------+------+-----+---------+----------------+
| id       | int(11)      | NO   | PRI | NULL    | auto_increment |
| age      | int(11)      | NO   |     | NULL    |                |
| birthday | date         | NO   |     | NULL    |                |
| ok       | tinyint(4)   | NO   |     | NULL    |                |
| name     | varchar(255) | NO   |     | NULL    |                |
+----------+--------------+------+-----+---------+----------------+

const conn = createConnection(config) として、 conn.getRepository(OkWoman) とすると、 ok_woman テーブルの操作を主に行なうためのレポジトリが得られる。 以下のようにして OkWoman インスタンスを DB に保存したり DB から取り出したりする。

const repo = conn.getRepository(OkWoman);
await repo.save({
    name: 'popuko',
    age: 18,
    birthday: new Date('2017-12-09'),
    ok: false
});
const record = await repo.findOne({ name: 'popuko' });

ここで、例えば repo.save({yeah: "yo"}); という風に、 OkWomanDeepPartialでないものを OkWoman のレポジトリで DB に保存しようとするとコンパイルエラーが出る。 DeepPartial<T> は、下のリンク先で定義されている。T[P]DeepPartial<T[P]> であるもの。Partial再帰版みたいなやつ。

typeorm/DeepPartial.ts at master · typeorm/typeorm · GitHub

こんな感じで TypeScript から DB 触れてしかも型も良い感じにチェックしてくれるから便利〜という代物である。

もっとがんばってくれ TypeORM

「こんな感じで TypeScript から DB 触れてしかも型も良い感じにチェックしてくれるから便利〜という代物である。」と言ったが、正直ムムっとなるところが多いので、そこについて話をする。 ムムっポイントは以下の2つ。

  1. DB から安全に Entity を取り出せていない
  2. DB に安全に Entity を保存できていない

要するにランタイムエラーがすごいたくさん出てくる。かなしい😢

DB から安全に Entity を取り出せていない

以下のように noOkWomanToInsert なるものを DB に保存して取り出してみる。冷静に考えてかなりトチ狂った変数名な気がする。

const noOkWomanToInsert = {
    name: 'popuko',
    age: 18,
    birthday: new Date('2017/12/09'),
    ok: false,
}

const conn = await createConnection(config);
console.log('connected mysql');
const repo = conn.getRepository(OkWoman);
await repo.save(noOkWomanToInsert);
const record = await repo.findOne({ name: 'popuko' });

このとき、 TypeORM の型定義によると record の型は OkWoman | undefined になっている。 DB に name'popuko' であるレコードが存在しない場合は undefined が返ってくる。それはそう。

では、レコードが存在する場合は OkWomanインスタンスであるオブジェクトが record に格納されているのかというと、実は全然そんなことない。 とりあえず中身を出力してみる。 すると、 Date 型と宣言した birthday には string 型の値が、 boolean 型と宣言した ok には number 型の値がセットされているのがわかる。

if (typeof record !== 'undefined') {
    console.log(record);
    // output: OkWoman { id: 1, name: 'popuko', age: 18, birthday: '2017-12-09', ok: 0 }
    // - birthday は string、ok は number となっている。
}

ok については言うまでもなく、DB 内で tinyint として保存しているのが原因である。 save する際に true を 1 、それ以外を 0 に変換してくれているようだ。(ソースコードの多分このあたりmysql では boolean 型を使えないので、 tinyint で代用している(し、 typeorm のデフォルト設定もそうなっている)のだが、いざ取り出す時には 1 を true、 0 を false にはしてくれない。

これはかなり不便だ。 まず第一に、以下のように実際に取り出される値(trueでなく1、あるいは Date でなく string)を操作するようなコードはコンパイラエラーを起こす。 なぜなら recordOkWoman の型を持っており、 record.okbooleanrecord.birthdayDate ということにされてしまっているので。

if (record.ok === 0) { console.log('she is not an ok_woman'); }
// - Compile error: Operator '===' cannot be applied to types 'boolean' and '0'.
if (record.birthday === '2017-12-09') { console.log(`Her birthday is ${record.birthday}`) }
// - Compile error: Operator '===' cannot be applied to types 'Date' and 'string'.

逆に、次のようなコードはコンパイルエラーは起こさないもののランタイムエラーを起こす。

console.log(`Her birtday is ${record.birthday.toISOString()}`);
// - TypeError: record.birthday.toISOString is not a function

正直かなり不愉快だ。 なんとかうまいこと TypeORM 側で良い感じに良いアレをして欲しいが、してくれてないので、私は現状 EntityListener を使って騙し騙しやっている。 EntityListenerEntityInsert やら Update やら Load やらのイベントが起きるタイミングで何らかの関数を実行するための機構である。 以下のような関数をデコレートして、 OkWoman が DB から取り出されるタイミングで birthdayok を型注釈に沿うような値に変換している。

@Entity('ok_women')
export class OkWoman {
    @PrimaryGeneratedColumn()
    id: number;

    @Column('varchar', { name: 'name' })
    name: string;

    @Column('int', { name: 'age' })
    age: number;

    @Column('date', { name: 'birthday' })
    birthday: Date;

    @Column('tinyint', { name: 'ok' })
    ok: boolean;

    @AfterLoad()
    converter() { // DB から取り出される時に birthday と ok の値を変換する
        this.birthday = new Date(this.birthday);
        this.ok = (<any>this.ok === 0) ? false : true;
    }
}

こうしてあげるとちゃんと birthdayDateokboolean の値がセットされる。が、正直ムムって感じだ😣 ちなみに、 birthday に関してはカラムの型を date でなく datetime にするとちゃんと Date 型として取り出される。 datetime はちゃんと Date に変換するように、TypeORM の mysql ドライバに書いてあるからなのだが、ムムってなる。

DB に安全に Entity を保存できていない

下のコードはコンパイルエラーを起こさないけどランタイムエラーは起きるよね、という話。

const repo = conn.getRepository(OkWoman);
await repo.save({ name: 'sosogu' });

repo.saveDeepPartial<OkWoman> を受け取るようになっているのでこうなってしまう。 冷静に考えて DeepPartial<OkWoman> 型はある変数が DB に保存できることを全く保証しない。

TypeORM は Custom Repository を作れるので、ここでもう少し安全な saveHogeHoge を作ってあげるのが良いと思う。 下みたいな感じ。

type ColumnsShouldInsert =
    | 'name'
    | 'age'
    | 'birthday'
    | 'ok'
export type InsertableOkWoman = Pick<OkWoman, ColumnsShouldInsert>;

@EntityRepository(OkWoman)
export class OkWomanRepository extends Repository<OkWoman> {

    saveInsertable(entity: InsertableOkWoman) {
        return this.save(entity);
    }

}

この Custom Repository を用いると、下のコードはちゃんとコンパイルエラーで弾かれる。

const conn = await createConnection(config);
const repo = conn.getCustomRepository(OkWomanRepository);
await repo.saveInsertable({ name: 'sosogu' });
// -- Compile error

ムムっ!😣

上の節で TypeORM の ムムっ!な部分とその対処法について述べた。 対処法は repository とか entity 自体をいじることになるので、結構微妙な気持ちになってしまう。 そんなに複雑でない DB をいじるときには knex.js なんかを使って自分で repository やら entity を 1 から実装した方が楽なんじゃないかと感じてしまう。 とはいうものの、(今回は面倒だから紹介してないが)テーブル間の関係なんかが増えてくると、やっぱり TypeORM(とかあるいは sequelize とか)を使う方が良いのだろう。

「DB から安全に Entity を取り出せていない」「DB に安全に Entity を保存できていない」も、現状の TypeScript の機能的にしょうがない気もする。 「Entity を安全に保存・取り出すために Entity の型注釈読み込んで動的に savefind を生成しますッ!」とか言い出すと「お前それ JavaScript でも同じこと言えんの?」という感じになりそう。 JavaScript との互換性重視する以上しょうがないのかも。 でもなんかうまい感じのモデル生成言語で良い感じにアレして欲しいなあと思いつつ、色々騙し騙しなんとか TypeORM とつきあっています。 おしまい。

おまけ

ソースコードはこんな感じです。

GitHub - azoson/typeorm-poyo: TypeORM のムムッなところについて