これは、なにをしたくて書いたもの?
TypeScriptのORMはどれを使えばいいのかなといろいろ思ったりするのですが、Drizzle ORMというものを1度試してみようかなということで。
TypeScriptのORMを探す
TypeScriptで使えるORMをいくつか探してみます。
- Prisma | Simplify working and interacting with databases
- TypeORM - Amazing ORM for TypeScript and JavaScript (ES7, ES6, ES5). Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms.
- MikroORM: TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. | MikroORM
- Drizzle ORM - next gen TypeScript ORM.
- Objection.js
クエリービルダー。
PrismaとTypeORMは以前に試してみたことがあります。
PrismaはMySQLで使うとCollationが固定されるのがちょっと嫌だったのと、テストにはVitestを使いたいのでデコレーターを使うTypeORMと
MikroORMは厳しいなと。
するとクエリービルダー系なのかなと思ったりするのですが、Drizzle ORMは系統が違いそうだったので1度試してみようと思ったのが
契機です。
Drizzle ORM
Drizzle ORMのWebサイトはこちら。
Drizzle ORM - next gen TypeScript ORM.
ドキュメントはこちら。
特徴としては、ORM的なものとクエリービルダーの両方をAPIとして備えているようです。
また、sql
演算子を使ってクエリーを書くこともできます。
Drizzle ORM - Magic sql`` operator
対応しているデータベースは、PostgreSQL、MySQL、SQLiteの3つです。
Drizzle Kitというマイグレーションツールもあります。
今回は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
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に沿って進めます。作成するテーブルは適宜読み替えます。
まずはschema.ts
というファイルを作る必要があるようです。これがスキーマ定義になるようですね。
スキーマについては、Drizzle ORMのドキュメントに詳しく書かれています。
データ型はこちらを見ながら指定。
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)から試していってみます。
作成したテストコード全体。
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 });
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); });
test('query(orm) test', async () => { // トランザクション await db.transaction(async (tx) => { ... });
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();
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 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);
関連データの取得。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
次はクエリービルダーです。
作成したテストコード全体。やっていること自体は、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 }), );
今回使っているのは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演算子
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]); }); });
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.singleFork
をtrue
にしています。
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を使う時の候補に入れておきましょう。