CLOVER🍀

That was when it all began.

TypeScriptのORM、Drizzle ORMをMySQLで試す

これは、なにをしたくて書いたもの?

TypeScriptのORMはどれを使えばいいのかなといろいろ思ったりするのですが、Drizzle ORMというものを1度試してみようかなということで。

TypeScriptのORMを探す

TypeScriptで使えるORMをいくつか探してみます。

クエリービルダー。

PrismaとTypeORMは以前に試してみたことがあります。

PrismaMySQLで使うとCollationが固定されるのがちょっと嫌だったのと、テストにはVitestを使いたいのでデコレーターを使うTypeORMと
MikroORMは厳しいなと。

するとクエリービルダー系なのかなと思ったりするのですが、Drizzle ORMは系統が違いそうだったので1度試してみようと思ったのが
契機です。

Drizzle ORM

Drizzle ORMのWebサイトはこちら。

Drizzle ORM - next gen TypeScript ORM.

ドキュメントはこちら。

Drizzle ORM - Overview

特徴としては、ORM的なものとクエリービルダーの両方をAPIとして備えているようです。

また、sql演算子を使ってクエリーを書くこともできます。

Drizzle ORM - Magic sql`` operator

対応しているデータベースは、PostgreSQLMySQLSQLiteの3つです。

Drizzle Kitというマイグレーションツールもあります。

Drizzle ORM - Overview

今回はDrizzle ORMをMySQLで試してみたいと思います。

環境

今回の環境はこちら。

$ node --version
v20.17.0


$ npm --version
10.8.2

MySQLは172.17.0.2で動作しているものとします。

 MySQL  localhost+ ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.4.2     |
+-----------+
1 row in set (0.0006 sec)

Node.jsプロジェクトを作成する

まずはNode.jsプロジェクトを作成します。確認はテストコードで行うことにします。

$ npm init -y
$ npm i -D typescript
$ npm i -D @types/node@v20
$ npm i -D prettier
$ npm i -D vitest

ECMAScript Modulesとします。

  "type": "module",

この時点での依存関係。

  "devDependencies": {
    "@types/node": "^20.16.5",
    "prettier": "^3.3.3",
    "typescript": "^5.5.4",
    "vitest": "^2.0.5"
  }

scriptsはこうしておきました。

  "scripts": {
    "build": "tsc --project .",
    "build:watch": "tsc --project . --watch",
    "typecheck": "tsc --project ./tsconfig.typecheck.json",
    "typecheck:watch": "tsc --project ./tsconfig.typecheck.json --watch",
    "test": "vitest run",
    "test:watch": "vitest watch",
    "format": "prettier --write src test"
  },

設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  },
  "include": [
    "src/**/*"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src/**/*", "test/**/*"
  ]
}

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 100
}

vite.config.ts

/// <reference types="vitest" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    environment: 'node',
    poolOptions: {
      forks: {
        singleFork: true,
      },
    },
  },
});

テストは単一プロセスで実行するようにします。これは後でまた説明します。

ソースコードsrcに、テストコードはtestに置くことにします。

$ mkdir src test

お題とDrizzle Kitを使ったマイグレーション

お題は、ユーザーと投稿という2つのテーブルを関連を持たせて作成することにします。

MySQLに接続するので、ひとまずこちらに習ってライブラリーをインストールしておきましょう。ドライバーはmysql2を使います。

$ npm i drizzle-orm mysql2
$ npm i -D drizzle-kit

MySQL / mysql2

drizzle-ormがDrizzle ORM本体で、drizzle-kitはDrizzle Kitですね。

この時点での依存関係。

  "devDependencies": {
    "@types/node": "^20.16.5",
    "drizzle-kit": "^0.24.2",
    "prettier": "^3.3.3",
    "typescript": "^5.5.4",
    "vitest": "^2.0.5"
  },
  "dependencies": {
    "drizzle-orm": "^0.33.0",
    "mysql2": "^3.11.0"
  }

次は、Drizzle KitのQuick Startに沿って進めます。作成するテーブルは適宜読み替えます。

Drizzle ORM - Quick start

まずはschema.tsというファイルを作る必要があるようです。これがスキーマ定義になるようですね。

スキーマについては、Drizzle ORMのドキュメントに詳しく書かれています。

Drizzle ORM - Overview

データ型はこちらを見ながら指定。

Drizzle ORM - MySQL column types

制約などはこちら。

Drizzle ORM - Indexes & Constraints

ひとまず、テーブルをひとつ書いてみました。

src/schema.ts

import { int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';

export const user = mysqlTable('user', {
  id: int('id').autoincrement().primaryKey(),
  firstName: varchar('first_name', { length: 10 }),
  lastName: varchar('last_name', { length: 10 }),
  age: int('age'),
});

次にDrizzle Kitの設定ファイルを作る必要があるようです。ここで作成したschema.tsを指定します。

drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'mysql',
  schema: './src/schema.ts',
  out: './drizzle',
});

Drizzle Kitのgenerateコマンドを実行してみます。

$ npx drizzle-kit generate

SQLファイルがひとつできたようです。

1 tables
user 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0000_brave_maximus.sql 🚀

これ自体はデータベースに接続しなくても実行できるようですね。

生成されたディレクトリ。

+$ tree drizzle
drizzle
├── 0000_brave_maximus.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

1 directory, 3 files

中身を見てみます。

drizzle/0000_brave_maximus.sql

CREATE TABLE `user` (
        `id` int AUTO_INCREMENT NOT NULL,
        `first_name` varchar(10),
        `last_name` varchar(10),
        `age` int,
        CONSTRAINT `user_id` PRIMARY KEY(`id`)
);

drizzle/metaディレクトリはいったん省略。+

マイグレーションを実行。

$ npx drizzle-kit migrate

これはさすがにムリですね。

 Error  Please provide required params for MySQL driver:
    [x] host: undefined
        port?:
        user?:
        password?:
    [x] database: undefined
        ssl?:

設定はどうしたらいいのだろうと思ったのですが、こちらで指定すればよさそうです。

Configuring Drizzle kit / dbCredentials

URL形式での指定と、各項目別の指定がありますが今回は項目別に指定しましょう。

drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'mysql',
  schema: './src/schema.ts',
  out: './drizzle',
  dbCredentials: {
    host: '172.17.0.2',
    port: 3306,
    database: 'practice',
    user: 'kazuhira',
    password: 'password',
  },
});

もう1度実行。

$ npx drizzle-kit migrate

今度はうまくいったようです。

MySQLでのテーブルを確認してみます。

 MySQL  localhost+ ssl  practice  SQL > show tables;
+----------------------+
| Tables_in_practice   |
+----------------------+
| __drizzle_migrations |
| user                 |
+----------------------+
2 rows in set (0.0022 sec)

目的のテーブルと、Drizzle Kitのマイグレーションを管理するテーブルがあります。

定義の確認。

 MySQL  localhost+ ssl  practice  SQL > describe user;
+------------+-------------+------+-----+---------+----------------+
| Field      | Type        | Null | Key | Default | Extra          |
+------------+-------------+------+-----+---------+----------------+
| id         | int         | NO   | PRI | NULL    | auto_increment |
| first_name | varchar(10) | YES  |     | NULL    |                |
| last_name  | varchar(10) | YES  |     | NULL    |                |
| age        | int         | YES  |     | NULL    |                |
+------------+-------------+------+-----+---------+----------------+
4 rows in set (0.0035 sec)

Drizzle Kit側のテーブル。

 MySQL  localhost+ ssl  practice  SQL > describe __drizzle_migrations;
+------------+-----------------+------+-----+---------+----------------+
| Field      | Type            | Null | Key | Default | Extra          |
+------------+-----------------+------+-----+---------+----------------+
| id         | bigint unsigned | NO   | PRI | NULL    | auto_increment |
| hash       | text            | NO   |     | NULL    |                |
| created_at | bigint          | YES  |     | NULL    |                |
+------------+-----------------+------+-----+---------+----------------+
3 rows in set (0.0051 sec)


 MySQL  localhost+ ssl  practice  SQL > select * from __drizzle_migrations;
+----+------------------------------------------------------------------+---------------+
| id | hash                                                             | created_at    |
+----+------------------------------------------------------------------+---------------+
|  1 | 03de383a0b84b97643c60718eee676f45dc8f5a67192a84a0496ce2b6bc9f8cf | 1725709923952 |
+----+------------------------------------------------------------------+---------------+
1 row in set (0.0009 sec)

意味は予想できますが、テーブルに入ったデータを見てもどのマイグレーションのことなのかパッとわかりませんね…。

投稿を表すテーブルを追加してみます。最初に作成したユーザーテーブルのidを参照しています。

src/schema.ts

import { int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';

export const user = mysqlTable('user', {
  id: int('id').autoincrement().primaryKey(),
  firstName: varchar('first_name', { length: 10 }),
  lastName: varchar('last_name', { length: 10 }),
  age: int('age'),
});

export const post = mysqlTable('post', {
  id: int('id').autoincrement().primaryKey(),
  title: varchar('title', { length: 255 }),
  url: text('url'),
  userId: int('user_id').references(() => user.id),
});

マイグレーションの生成。

$ npx drizzle-kit generate

...

2 tables
post 4 columns 0 indexes 1 fks
user 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0001_young_photon.sql 🚀

生成されたSQL

$ cat drizzle/0001_young_photon.sql
CREATE TABLE `post` (
        `id` int AUTO_INCREMENT NOT NULL,
        `title` varchar(255),
        `url` text,
        `user_id` int,
        CONSTRAINT `post_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `post` ADD CONSTRAINT `post_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE no action ON UPDATE no action;

適用。

$ npx drizzle-kit migrate

テーブルが作成されました。

 MySQL  localhost+ ssl  practice  SQL > show tables;
+----------------------+
| Tables_in_practice   |
+----------------------+
| __drizzle_migrations |
| post                 |
| user                 |
+----------------------+
3 rows in set (0.0023 sec)

マイグレーションの管理テーブルにもデータが増えています。

 MySQL  localhost+ ssl  practice  SQL > select * from __drizzle_migrations;
+----+------------------------------------------------------------------+---------------+
| id | hash                                                             | created_at    |
+----+------------------------------------------------------------------+---------------+
|  1 | 03de383a0b84b97643c60718eee676f45dc8f5a67192a84a0496ce2b6bc9f8cf | 1725709923952 |
|  2 | f74028e28beec4269678d0e608265b0f14847f22f17fe0c195867d9b9466eac7 | 1725710845510 |
+----+------------------------------------------------------------------+---------------+
2 rows in set (0.0007 sec)

ユーザーから投稿に、投稿からユーザー対してそれぞれ関連を関連をつけてみます。

src/schema.ts

import { relations } from 'drizzle-orm';
import { int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core';

export const user = mysqlTable('user', {
  id: int('id').autoincrement().primaryKey(),
  firstName: varchar('first_name', { length: 10 }),
  lastName: varchar('last_name', { length: 10 }),
  age: int('age'),
});

export const userRelation = relations(user, ({ many }) => ({
  posts: many(post),
}));

export const post = mysqlTable('post', {
  id: int('id').autoincrement().primaryKey(),
  title: varchar('title', { length: 255 }),
  url: text('url'),
  userId: int('user_id').references(() => user.id),
});

export const postRelation = relations(post, ({ one }) => ({
  user: one(user, {
    fields: [post.userId],
    references: [user.id],
  }),
}));

この部分がone-to-many、

export const userRelation = relations(user, ({ many }) => ({
  posts: many(post),
}));

Drizzle Queries / Declaring relations / One-to-many

この部分がone-to-oneです。

export const postRelation = relations(post, ({ one }) => ({
  user: one(user, {
    fields: [post.userId],
    references: [user.id],
  }),
}));

Drizzle Queries / Declaring relations / One-to-one

このあたりは、リレーションの定義方法を見るとよいでしょう。

Drizzle Queries / Declaring relations

今回はマイグレーションの生成と適用は不要です。

マイグレーションの生成を行おうとしても、スキップされます。

$ npx drizzle-kit generate

...


2 tables
post 4 columns 0 indexes 1 fks
user 4 columns 0 indexes 0 fks

No schema changes, nothing to migrate 😴

これでデータベース側の準備ができました。

Drizzle ORMを使う

では、Drizzle ORMを使っていきます。

Drizzle ORMにはORMとクエリービルダー、そしてsql演算子があるということでしたが、それぞれ簡単に使っていってみましょう。

Query(ORM)

まずはQuery(ORM)から試していってみます。

Drizzle ORM - Query

作成したテストコード全体。

test/query.test.ts

import { beforeEach, expect, test } from 'vitest';

import { drizzle } from 'drizzle-orm/mysql2';
import mysql2 from 'mysql2';

import * as schema from '../src/schema.js';
import { post, user } from '../src/schema.js';
import { and, asc, eq, gt } from 'drizzle-orm';

const connection = mysql2.createConnection({
  host: '172.17.0.2',
  port: 3306,
  database: 'practice',
  user: 'kazuhira',
  password: 'password',
});

const db = drizzle(connection, { mode: 'default', schema });

beforeEach(async () => {
  await db.delete(post);
  await db.delete(user);
});

test('query(orm) test', async () => {
  // トランザクション
  await db.transaction(async (tx) => {
    // データ登録
    const katsuoId = await tx
      .insert(user)
      .values({ firstName: 'カツオ', lastName: '磯野', age: 11 })
      .$returningId(); // auto incrementの結果を取得
    const wakemeId = await tx
      .insert(user)
      .values({ firstName: 'ワカメ', lastName: '磯野', age: 9 })
      .$returningId();

    // 複数行登録
    await tx.insert(post).values([
      {
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
        userId: katsuoId[0].id,
      },
      {
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
        userId: katsuoId[0].id,
      },
    ]);

    await tx.insert(post).values({
      title: 'TypeScriptでExpress',
      url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      userId: wakemeId[0].id,
    });
  });

  await db.transaction(async (tx) => {
    // 1件取得(単一検索条件)
    const katsuo = await tx.query.user.findFirst({
      where: eq(user.firstName, 'カツオ'),
    });

    expect(katsuo).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    // 1件取得(複合検索条件)
    const wakame = await tx.query.user.findFirst({
      where: and(eq(user.firstName, 'ワカメ'), eq(user.age, 9)),
    });

    expect(wakame).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const sazae = await tx.query.user.findFirst({ where: eq(user.firstName, 'サザエ') });

    expect(sazae).toBeUndefined();

    // 全件取得
    const isonoFamily = await tx.query.user.findMany({
      orderBy: (user, { desc }) => [desc(user.age)],
    });

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );
    expect(isonoFamily[1]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const noUsers = await tx.query.user.findMany({
      where: gt(user.age, 15),
    });

    expect(noUsers).toHaveLength(0);

    // one-to-one
    const postByWakame = await tx.query.post.findFirst({
      where: eq(post.title, 'TypeScriptでExpress'),
      with: {
        user: true,
      },
    });

    expect(postByWakame).toEqual(
      expect.objectContaining({
        title: 'TypeScriptでExpress',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      }),
    );

    expect(postByWakame?.user).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // one-to-many
    const katsuoWithPosts = await tx.query.user.findFirst({
      where: eq(user.firstName, 'カツオ'),
      with: {
        posts: {
          orderBy: asc(post.url),
        },
      },
    });

    expect(katsuoWithPosts).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    expect(katsuoWithPosts?.posts).toHaveLength(2);
    expect(katsuoWithPosts?.posts[0]).toEqual(
      expect.objectContaining({
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
      }),
    );
    expect(katsuoWithPosts?.posts[1]).toEqual(
      expect.objectContaining({
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
      }),
    );
  });
});

順に説明していきます。

MySQLへの接続と、Drizzle ORMのセットアップ。

const connection = mysql2.createConnection({
  host: '172.17.0.2',
  port: 3306,
  database: 'practice',
  user: 'kazuhira',
  password: 'password',
});

const db = drizzle(connection, { mode: 'default', schema });

MySQL / mysql2

schemaを指定しておくことで、この後でDrizzle ORMを使う時にスキーマの型を使うことができます。

import * as schema from '../src/schema.js';

...

const db = drizzle(connection, { mode: 'default', schema });

テストの実行前にデータを削除。

beforeEach(async () => {
  await db.delete(post);
  await db.delete(user);
});

Drizzle ORM - Delete

トランザクション

test('query(orm) test', async () => {
  // トランザクション
  await db.transaction(async (tx) => {

  ...

  });

Drizzle ORM - Transactions

drizzle#transactionに渡された関数が正常に終了するとコミットされ、例外で抜けるとロールバックします。引数に渡される
トランザクションを表すオブジェクトを使うことで、任意のタイミングでロールバックすることもできます。

また、クエリーの実行はこのトランザクションを表すオブジェクト経由で行います。

今回はこのトランザクション内でデータの登録を行います。

単一行の登録(insert)。

    // データ登録
    const katsuoId = await tx
      .insert(user)
      .values({ firstName: 'カツオ', lastName: '磯野', age: 11 })
      .$returningId(); // auto incrementの結果を取得
    const wakemeId = await tx
      .insert(user)
      .values({ firstName: 'ワカメ', lastName: '磯野', age: 9 })
      .$returningId();

Drizzle ORM - Insert

auto incrementのような値については、$returningIdで取得できます。

複数行のinsert。

    // 複数行登録
    await tx.insert(post).values([
      {
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
        userId: katsuoId[0].id,
      },
      {
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
        userId: katsuoId[0].id,
      },
    ]);

関連データも登録しておきます。

    // 複数行登録
    await tx.insert(post).values([
      {
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
        userId: katsuoId[0].id,
      },
      {
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
        userId: katsuoId[0].id,
      },
    ]);

次は検索です。

  await db.transaction(async (tx) => {

    // ここで検索を行う

  });

1件取得を行う例。findFirstで行います。

    // 1件取得(単一検索条件)
    const katsuo = await tx.query.user.findFirst({
      where: eq(user.firstName, 'カツオ'),
    });

    expect(katsuo).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    // 1件取得(複合検索条件)
    const wakame = await tx.query.user.findFirst({
      where: and(eq(user.firstName, 'ワカメ'), eq(user.age, 9)),
    });

    expect(wakame).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const sazae = await tx.query.user.findFirst({ where: eq(user.firstName, 'サザエ') });

    expect(sazae).toBeUndefined();

Drizzle Queries / Find first

Drizzle ORMのセットアップ時にschemaを指定しておくことで

const db = drizzle(connection, { mode: 'default', schema });

queryの後にuserなどの型を書くことができるようになります。

    const katsuo = await tx.query.user.findFirst({
      where: eq(user.firstName, 'カツオ'),
    });

複数件取得の例。findManyで行います。

    // 全件取得
    const isonoFamily = await tx.query.user.findMany({
      orderBy: (user, { desc }) => [desc(user.age)],
    });

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );
    expect(isonoFamily[1]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const noUsers = await tx.query.user.findMany({
      where: gt(user.age, 15),
    });

    expect(noUsers).toHaveLength(0);

Drizzle Queries / Find many

関連データの取得。One to OneであってもOne to Manyであっても、withで指定します。

    // one-to-one
    const postByWakame = await tx.query.post.findFirst({
      where: eq(post.title, 'TypeScriptでExpress'),
      with: {
        user: true,
      },
    });

    expect(postByWakame).toEqual(
      expect.objectContaining({
        title: 'TypeScriptでExpress',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      }),
    );

    expect(postByWakame?.user).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // one-to-many
    const katsuoWithPosts = await tx.query.user.findFirst({
      where: eq(user.firstName, 'カツオ'),
      with: {
        posts: {
          orderBy: asc(post.url),
        },
      },
    });

    expect(katsuoWithPosts).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    expect(katsuoWithPosts?.posts).toHaveLength(2);
    expect(katsuoWithPosts?.posts[0]).toEqual(
      expect.objectContaining({
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
      }),
    );
    expect(katsuoWithPosts?.posts[1]).toEqual(
      expect.objectContaining({
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
      }),
    );

Drizzle Queries / Include relations

このあたりの補完も、schemaを指定していればOKです。

Query(ORM)はこんなところです。

Select

次はクエリービルダーです。

Drizzle ORM - Select

作成したテストコード全体。やっていること自体は、Query(ORM)の時と同じです。

test/select.test.ts

import { drizzle } from 'drizzle-orm/mysql2';
import mysql2 from 'mysql2';
import { beforeEach, expect, test } from 'vitest';
import * as schema from '../src/schema.js';
import { post, user } from '../src/schema.js';
import { and, asc, desc, eq, gt } from 'drizzle-orm';

const connection = mysql2.createConnection({
  host: '172.17.0.2',
  port: 3306,
  database: 'practice',
  user: 'kazuhira',
  password: 'password',
});

const db = drizzle(connection, { mode: 'default', schema });

beforeEach(async () => {
  await db.delete(post);
  await db.delete(user);
});

test('select test', async () => {
  await db.transaction(async (tx) => {
    // データ登録
    const katsuoId = await tx
      .insert(user)
      .values({ firstName: 'カツオ', lastName: '磯野', age: 11 })
      .$returningId(); // auto incrementの結果を取得
    const wakemeId = await tx
      .insert(user)
      .values({ firstName: 'ワカメ', lastName: '磯野', age: 9 })
      .$returningId();

    // 複数行登録
    await tx.insert(post).values([
      {
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
        userId: katsuoId[0].id,
      },
      {
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
        userId: katsuoId[0].id,
      },
    ]);

    await tx.insert(post).values({
      title: 'TypeScriptでExpress',
      url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      userId: wakemeId[0].id,
    });
  });

  await db.transaction(async (tx) => {
    // 1件取得(単一検索条件)
    const katsuo = await tx.select().from(user).where(eq(user.firstName, 'カツオ'));

    expect(katsuo).toHaveLength(1);
    expect(katsuo[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    // 1件取得(複合検索条件)
    const wakame = await tx
      .select()
      .from(user)
      .where(and(eq(user.firstName, 'ワカメ'), eq(user.age, 9)));

    expect(wakame).toHaveLength(1);
    expect(wakame[0]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const noUsers = await tx.select().from(user).where(gt(user.age, 15));
    expect(noUsers).toHaveLength(0);

    // 全件取得
    const isonoFamily = await tx.select().from(user).orderBy(desc(user.age));

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );
    expect(isonoFamily[1]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // inner join(one-to-one)
    const postByWakame = await tx
      .select()
      .from(post)
      .innerJoin(user, eq(post.userId, user.id))
      .where(eq(post.title, 'TypeScriptでExpress'));

    expect(postByWakame[0].post).toEqual(
      expect.objectContaining({
        title: 'TypeScriptでExpress',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      }),
    );

    expect(postByWakame[0].user).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // inner join(one-to-many)
    const katsuoWithPosts = await tx
      .select()
      .from(user)
      .innerJoin(post, eq(user.id, post.userId))
      .where(eq(user.firstName, 'カツオ'))
      .orderBy(asc(post.url));

    expect(katsuoWithPosts).toHaveLength(2);

    for (const katsuoWithPost of katsuoWithPosts) {
      // 同じデータが2つ
      expect(katsuoWithPost.user).toEqual(
        expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
      );
    }

    expect(katsuoWithPosts[0].post).toEqual(
      expect.objectContaining({
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
      }),
    );
    expect(katsuoWithPosts[1].post).toEqual(
      expect.objectContaining({
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
      }),
    );
  });
});

なので、Query(ORM)と全く同じ部分は省略します。

単一データの取得。もっとも、クエリービルダーの場合は取得件数によって使用するメソッドが変わったりしません。結果も配列になります。

    // 1件取得(単一検索条件)
    const katsuo = await tx.select().from(user).where(eq(user.firstName, 'カツオ'));

    expect(katsuo).toHaveLength(1);
    expect(katsuo[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );

    // 1件取得(複合検索条件)
    const wakame = await tx
      .select()
      .from(user)
      .where(and(eq(user.firstName, 'ワカメ'), eq(user.age, 9)));

    expect(wakame).toHaveLength(1);
    expect(wakame[0]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

    // 未存在の場合
    const noUsers = await tx.select().from(user).where(gt(user.age, 15));
    expect(noUsers).toHaveLength(0);

複数件の取得。

    // 全件取得
    const isonoFamily = await tx.select().from(user).orderBy(desc(user.age));

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0]).toEqual(
      expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
    );
    expect(isonoFamily[1]).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

Query(ORM)よりも、よりSQLに近い形ですね。

join。まずはOne to One。

    // inner join(one-to-one)
    const postByWakame = await tx
      .select()
      .from(post)
      .innerJoin(user, eq(post.userId, user.id))
      .where(eq(post.title, 'TypeScriptでExpress'));

    expect(postByWakame[0].post).toEqual(
      expect.objectContaining({
        title: 'TypeScriptでExpress',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
      }),
    );

    expect(postByWakame[0].user).toEqual(
      expect.objectContaining({ firstName: 'ワカメ', lastName: '磯野', age: 9 }),
    );

Drizzle ORM - Joins

今回使っているのはinner joinですね。

Joins [SQL] / Inner Join

One to Many。

    // inner join(one-to-many)
    const katsuoWithPosts = await tx
      .select()
      .from(user)
      .innerJoin(post, eq(user.id, post.userId))
      .where(eq(user.firstName, 'カツオ'))
      .orderBy(asc(post.url));

    expect(katsuoWithPosts).toHaveLength(2);

    for (const katsuoWithPost of katsuoWithPosts) {
      // 同じデータが2つ
      expect(katsuoWithPost.user).toEqual(
        expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
      );
    }

    expect(katsuoWithPosts[0].post).toEqual(
      expect.objectContaining({
        title: 'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
      }),
    );
    expect(katsuoWithPosts[1].post).toEqual(
      expect.objectContaining({
        title: 'JestでTypeScriptのテストを書く',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
      }),
    );

ひとつのユーザーに対して、投稿が2つ関連付けられたデータになりますが、取得したデータは以下の形式になります。

{
    user: {
        id: number;
        firstName: string | null;
        lastName: string | null;
        age: number | null;
    };
    post: {
        id: number;
        title: string | null;
        url: string | null;
        userId: number | null;
    };
}[]

なので、ユーザーに関しては同じデータが2つ入ることになりますね。

    for (const katsuoWithPost of katsuoWithPosts) {
      // 同じデータが2つ
      expect(katsuoWithPost.user).toEqual(
        expect.objectContaining({ firstName: 'カツオ', lastName: '磯野', age: 11 }),
      );
    }

データの取得件数的にはわかりますが、別々のオブジェクトとして、しかも並んで扱うのでちょっと驚きます。

sql演算子

最後はsql演算子です。

Drizzle ORM - Magic sql`` operator

sql演算子を使うと、SQL文そのものやSQL文の一部を文字列で自由に組み立てることができます。

なのですが、これまで使ってきたスキーマと統合できるわけではありません。型安全性は大きく崩れます。ちょっと扱いづらかったので、
今回は簡単に済ませることにします。

作成したテストコード全体。

test/sql.test.ts

import { drizzle, MySqlQueryResult } from 'drizzle-orm/mysql2';
import mysql2, { QueryResult } from 'mysql2';
import { beforeEach, expect, test } from 'vitest';
import { post, user } from '../src/schema.js';
import { sql } from 'drizzle-orm';

const connection = mysql2.createConnection({
  host: '172.17.0.2',
  port: 3306,
  database: 'practice',
  user: 'kazuhira',
  password: 'password',
});

const db = drizzle(connection, { mode: 'default' });

beforeEach(async () => {
  await db.delete(post);
  await db.delete(user);
});

test('sql operator test', async () => {
  // トランザクション
  await db.transaction(async (tx) => {
    // データ登録
    const katsuoId = await tx
      .insert(user)
      .values({ firstName: 'カツオ', lastName: '磯野', age: 11 })
      .$returningId(); // auto incrementの結果を取得
    const wakemeId = await tx
      .insert(user)
      .values({ firstName: 'ワカメ', lastName: '磯野', age: 9 })
      .$returningId();
  });

  type User = {
    id: number;
    firstName: string;
    lastName: string;
    age: number;
  };

  await db.transaction(async (tx) => {
    const isonoFamily = await tx.execute(
      sql`select * from ${user} where ${user.lastName} = ${'磯野'} order by ${user.age} desc`,
    );

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0][0]).toEqual(
      expect.objectContaining({ first_name: 'カツオ', last_name: '磯野', age: 11 }),
    );
    expect(isonoFamily[0][1]).toEqual(
      expect.objectContaining({ first_name: 'ワカメ', last_name: '磯野', age: 9 }),
    );

    expect(isonoFamily).not.toBeUndefined();
    console.log(isonoFamily[1]);
  });
});

sql演算子を使っているのはここですね。

  await db.transaction(async (tx) => {
    const isonoFamily = await tx.execute(
      sql`select * from ${user} where ${user.lastName} = ${'磯野'} order by ${user.age} desc`,
    );

    expect(isonoFamily).toHaveLength(2);
    expect(isonoFamily[0][0]).toEqual(
      expect.objectContaining({ first_name: 'カツオ', last_name: '磯野', age: 11 }),
    );
    expect(isonoFamily[0][1]).toEqual(
      expect.objectContaining({ first_name: 'ワカメ', last_name: '磯野', age: 9 }),
    );

    expect(isonoFamily).not.toBeUndefined();
    console.log(isonoFamily[1]);
  });

今回は2件のデータを取得していますが、2次元の配列が返ってきてデータとデータ構造に分かれます。

データの方はこんな感じですね。スキーマ定義とは関連がなく、テーブルの構造がそのまま返ってきている感じです。

    expect(isonoFamily[0][0]).toEqual(
      expect.objectContaining({ first_name: 'カツオ', last_name: '磯野', age: 11 }),
    );
    expect(isonoFamily[0][1]).toEqual(
      expect.objectContaining({ first_name: 'ワカメ', last_name: '磯野', age: 9 }),
    );

ではもうひとつの方はというと、こちらで確認していますが

    expect(isonoFamily).not.toBeUndefined();
    console.log(isonoFamily[1]);

標準出力に書き出された内容はこのようになっています。

[
  `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
  `first_name` VARCHAR(10),
  `last_name` VARCHAR(10),
  `age` INT
]

取得したカラムの定義そのものですね。

スキーマとは関連のない型なのでちょっと扱いづらく、またMySQLの場合は以下のような問題がありコンパイルを通すのが困難です。
※Vitestで実行しているのでテストコードの型チェックをすり抜けています

[BUG]: drizzle execute is not behaving as expected · Issue #661 · drizzle-team/drizzle-orm · GitHub

今回はSQL文全体をsql演算子で記述しましたが、部分的に書いてクエリービルダーと統合することもできたりしますが今回は省略します。

Vitestの実行をシングルプロセスで

オマケ的に。

今回のVitestの設定は、以下のようにpoolOptions.forks.singleForktrueにしています。

vite.config.ts

/// <reference types="vitest" />
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    environment: 'node',
    poolOptions: {
      forks: {
        singleFork: true,
      },
    },
  },
});

Configuring Vitest / poolOptions / poolOptions.forks / poolOptions.forks.singleFork

Vitestはデフォルトで複数プロセスでテストを実行しようとするので、今回のように同じデータベース、同じテーブルに対して複数のテストが
同時に更新や削除をすると困ったことになるので、このようにしました。

おわりに

TypeScriptのORM、Drizzle ORMを試してみました。

やや癖があったり型の扱い自体はPrismaの方が強力だなとは思ったりするのですが、Prismaよりはその世界観に入り込まないと使いこなせない
感じがするので、こういうのもひとつ覚えておくと切り替えやすくなったりするのかなと思います。

このブログでTypeScriptのORMを使う時の候補に入れておきましょう。