🐣

これから始めるDrizzleORM:導入〜実装まで

2024/06/05に公開
2

はじめに

こんにちは。株式会社トリドリでバックエンドエンジニアをしている松田です!
今回はタイトルの通りTypeScriptのモダンなORMであるDrizzle ORMをこれから使ってみようとしている人や概要だけでも知っておきたいという人向けに、導入して使ってみるところまでを書いた記事になります。

実施背景と達成したいこと

業務改善システムの新規開発にてRemix × DrizzleORMを使っていこうということになり実装をはじめました。しかしDrizzleORMに関する情報はprismaなどに比べるとあまり多くはなく、これから取り組む方向けに参考情報を増やしていこうと思い、記事を書くことにしました。

実装の際、少しでも参考になることを願っています!
間違いがあったり、もっと情報欲しいなーと思われたりしたら気兼ねなくコメントいただけますと幸いです!

RemixとDrizzleORMの簡単な紹介

  • Remixとは
    • ReactをベースとしたフルスタックWebフレームワーク
    • 従来のWeb標準を活用した、シンプルで効率的なデータローディングとフォーム処理に焦点を当てている
  • Drizzle ORMとは
    • TypeScriptを前提としたORMで、型安全なクエリを記述できる
    • 軽量で高速なORM
    • マイグレーション機能も提供している
    • 複数のデータベースをサポートしている

🤔 フォルダ構成を考えてみた

背景

drizzleのドキュメントを見ていけば何の設定を書けばいいかは見えてくるけど、フォルダ構成はドキュメントを読み自身のプロジェクトに落とし込む必要があるため、最初に置き場を考えました。

やったこと

drizzleのドキュメントを参考にし、今回Remixプロジェクトに対し以下のようにディレクトリ構成を用意しました。

📂 sample-project
  📂 dockerfiles
    - Dockerfile.dev
  📂 front
    📂 app
      📂 db
        📂 drizzle
          - (ここにmigrationファイルを出力させる)
        - index.ts・・・データベース接続情報
        - schema.ts・・・スキーマ。これを元にmigrationファイルが作成される
    - drizzle.config.ts・・・drizzle ORMの設定ファイル。migrationファイルのディレクトリ指定、データベース接続情報など書く
    - package-lock.json
    - package.json
    - node_modules
    - (その他略)
- .gitignore
- compose.yaml

作成したフォルダ構成について振り返り

今回sample-projectという名前でRemixプロジェクトを立ち上げて、その中にdrizzleを導入する形となっています。プロジェクトルート直下にfrontディレクトリを切ってそこにRemixプロジェクトを導入しています。

drizzle関係は以下のポイントで配置しました。

  • Remixプロジェクトルートにdrizzleの設定ファイルdrizzle.config.tsを配置
  • app配下にdbディレクトリを置いてDBマイグレーション関係やDB接続を行うためのファイルを配置

公式ドキュメントにある <project root> が今回frontディレクトリにあたるので、front/drizzle.config.tsとしています。

同様に、<src> が今回front/appディレクトリにあたるので、front/app配下にindex.ts, schema.tsを置いています。

drizzleフォルダに関しては、ドキュメントでは<project root>においていますが、今回app/db/配下に配置しました。drizzleフォルダに出力されるマイグレーション関係のファイルはdb周りのファイルなのでまとめておこうという意図です。

🖋️ Drizzleの導入、設定をやっていこう

以下の公式ドキュメントに沿って導入を進めていきます。
https://orm.drizzle.team/docs/get-started-mysql#mysql-2

今回Remixプロジェクトに導入していますが、Remixやその他環境構築周りは主題ではないのでアコーディオン内に書いていきます。

Dockerの設定
compose.yaml
version: '3.9'
services:
  app:
    build:
      context: .
      dockerfile: ./dockerfiles/Dockerfile.dev
    ports:
      - "5173:5173"
    environment:
      TZ: "Asia/Tokyo"
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_HOST: db
      DB_PORT: 3306
      DB_NAME: ${DB_NAME}
    volumes:
      - ./front:/usr/src/app
    stdin_open: true
    tty: true
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  mysql-data:

dockerfiles/Dockerfile.dev
FROM node:20-alpine

WORKDIR /usr/src/app

COPY ./front .

CMD ["sh", "-c", "npm install && npm run drizzle:generate && npm run drizzle:migrate && npm run dev"]

EXPOSE 5173

これでアプリケーションサービスとデータベースサービスが起動するようになりました。
Remixは5173ポートを使っているのでdocker側も合わせています。
「npm run drizzle:generate」と「npm run drizzle:migrate」コマンドは後から設定しますが、先に書いておいてます。

1. Drizzleパッケージをインストールする

Drizzleにはベースとなるdrizzle-ormパッケージと、マイグレーション関係を実行するためのdrizzle-kitパッケージの2つがあります。

まずはパッケージのインストールから行います。drizzle-kitは開発環境のみあれば良いので-Dオプションがついています。

ターミナル
npm i drizzle-orm mysql2
npm i -D drizzle-kit
環境変数を扱うためにdotenvを入れておく

https://www.npmjs.com/package/dotenv

ターミナル
$ npm install dotenv

インストールができたら.envファイルを作成し、環境変数を記述します。

.env
DB_USER=root
DB_PASSWORD=password
DB_NAME=sample-project-development
DB_PORT=3306
MYSQL_ROOT_PASSWORD=password
MYSQL_DATABASE=sample-project-development
MYSQL_USER=root
MYSQL_PASSWORD=password

2. DB接続設定を書く

接続方法はconnectionとpoolの2つがありますが、公式にてclient connectionが勧められているのでそちらを使用します。

front/app/db/index.ts
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import * as schema from "./schema";

dotenv.config();

export const connection = await mysql.createConnection({
	host: process.env.DB_HOST,
	user: process.env.DB_USER,
	password: process.env.DB_PASSWORD,
	database: process.env.DB_NAME,
	port: Number(process.env.DB_PORT),
});

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

export default db;

ドキュメントと若干違う点について補足

DB接続だけだと以下の設定でよいです(参考

index.ts
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";

const connection = await mysql.createConnection({
  host: "host",
  user: "user",
  database: "database",
  ...
});

const db = drizzle(connection);

export default db;

ORMのクエリを活用できる状態にするためには以下のようにschema情報も追記する必要があります。(参考
schema指定の際、mysqlであれば mode: 'default' の指定が必要です。(参考

front/app/db/index.ts
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import * as schema from "./schema";

const connection = await mysql.createConnection({
  host: "host",
  user: "user",
  database: "database",
  ...
});

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

export default db;

3. drizzleの設定ファイルを書く

front/drizzle.config.ts
import dotenv from "dotenv";
import { defineConfig } from "drizzle-kit";

dotenv.config();

export default defineConfig({
	dialect: "mysql",
	schema: "./app/db/schema.ts",
	out: "./app/db/drizzle",
	dbCredentials: {
		user: process.env.DB_USER || "root",
		password: process.env.DB_PASSWORD || "password",
		host: String(process.env.DB_HOST) || "localhost",
		port: Number(process.env.DB_PORT) || 3306,
		database: String(process.env.DB_NAME || "sample-project-development"),
	},
});

package.jsonにdrizzleのコマンドを書いておく
package.json
{
  // ~
  "scripts": {
    // ~
    "drizzle:generate": "npx drizzle-kit generate",
    "drizzle:migrate": "npx drizzle-kit migrate",
        "drizzle:push": "npx drizzle-kit push",
    "drizzle:drop": "npx drizzle-kit drop"
  },

4. マイグレーションファイルの設定を書く

マイグレーションの実施は以下の通りです

  • schemaファイルの記述
  • マイグレーションファイルの作成
  • マイグレーションの実行

では順番に見ていきます。

4-1. schemaファイルの記述

schema定義の方法は以下の3つがありますが、今回は1つのファイルに書く方針でいきます。

  • 1File ← 今回の方針
  • Separate Files
  • Separate Folders

シンプルな例としてusersテーブルを、idとnameカラムで生成します。

schema.ts
import { mysqlTable, bigint, varchar } from 'drizzle-orm/mysql-core';

export const clients = mysqlTable('clients', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  name: varchar('name', { length: 255 }).notNull(),
});

4-2. マイグレーションファイルの作成

これは先ほどpackage.jsonに書いたコマンドで実行できます。

$ npm run drizzle:generate

4-3. マイグレーションの実行

$ npm run drizzle:migrate

マイグレーションファイルの生成と実行は、今回dockerコンテナ起動時に実行しています。

これでデータベースにusersテーブルが作成されました!

さらに、usersテーブルに1:多で紐づくpostsテーブルを加えておきます。

front/app/db/schema.ts
import { relations } from 'drizzle-orm';
import { mysqlTable, bigint, varchar } from 'drizzle-orm/mysql-core';

export const users = mysqlTable('users', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  name: varchar('name', { length: 255 }).notNull(),
});

export const posts = mysqlTable('posts', {
  id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
  title: varchar('title', { length: 255 }).notNull(),
  content: varchar('content', { length: 255 }).notNull(),
  userId: bigint('user_id', { mode: 'number' }).notNull(),
});

// relations
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

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

テーブル間の関係性は、テーブル定義とは別に、ormが提供しているrelationsを使って定義します。上の例で言うと、usersとpostsそれぞれ定義をしています。

👀 CRUDの操作感を見てみよう

CRUD実行サンプル

設定ができたので、実際のCRUD操作をして使用感を確認していきましょう!

使用例
// 全件検索
const users = await db.query.users.findMany();

// 条件検索
const users = await db.query.users.findFirst({
  where: eq(users.name, "ユーザー1")
});

// include relations
const users = await db.query.users.findMany({
  with: {
    posts: true,
  },
});

// Insert
await db.insert(users).values({ name: 'Andrew' });

// Update
await db.update(users)
  .set({ name: 'Mr. Dan' })
  .where(eq(users.name, 'Dan'));

良い点と微妙な点、他のORMとの比較

  • 良かった点
    • SQLライクにクエリがかけるのがシンプルで直感的に書けると感じました。Docsに書かれている「If you know SQL - you know Drizzle.」という言葉通りだと感じました。
    • 学習コストが低いこと
    • 設定が楽なこと
    • 多くのデータベースと互換性があること
  • 微妙な点
    • マイグレーション戦略が複雑になること
    • seedの仕組みが標準で備わっていないこと
    • 参考文献が多いとは言えないこと
  • Drizzle ORMと他のORMの比較
    • よく使われるORMとしてTypeORMやprismaがあると思います。これらと比較するとDrizzleは軽量、セットアップが楽、シンプルな記述で書けるというメリットがあります。TypeORMなどは大規模プロジェクトに使われることも多いようですが、Drizzleは比較的小〜中規模で使われることが多い傾向にあるようです。

🤯 困ったこと

上記の設定で開発開始できる状態になりました。ただいくつか懸念点があることに気づきました。

マイグレーションファイルの衝突で事故多発しそう

チーム開発でブランチを分けて、異なるブランチでマイグレーションファイルの生成&実行をした場合、「_journal.json」を適切に書き直す必要があることに気づきました。

_journal.jsonは以下のようになっており、適用したmigrationファイルの適用順序が記されています。

_journal.json
{
  "version": "6",
  "dialect": "mysql",
  "entries": [
    {
      "idx": 0,
      "version": "5",
      "when": 1716941150932,
      "tag": "0000_lame_kat_farrell",
      "breakpoints": true
    },
    {
      "idx": 1,
      "version": "5",
      "when": 1717026035421,
      "tag": "0001_futuristic_rattler",
      "breakpoints": true
    }
  ]
}

具体的に言うとdevelopブランチから切ったfeature/aブランチとfeature/bブランチがあり、先にfeature/aブランチがdevelopにマージされた際のfeature/bブランチでのコンフリクト解消作業が必要となります。
これはめんどくさいことになりそうだと。

同様の課題がないか調べたところ、以下のdiscussionが見つかりました

https://github.com/drizzle-team/drizzle-orm/discussions/1104

simplest way is create migration file in merge CI

merge CIでmigrationファイルを作成(して適用)するのがシンプルな方法、とあります。

なるほど、たしかに?

ローカル開発とリリース用で分類するという作戦は良さそう。
ただローカル環境と本番環境とは別に、ステージング環境があると話が厄介になるなと。

結論、以下の戦略で取り組むのが現状の最適解かと考えました。具体的コードは未実装なので書けませんが戦略の概要だけ共有できればと思っています。(機会があれば詳細を追記します)

  • ローカル、開発環境、本番環境ごとに、マイグレーション関連のディレクトリを分ける
  • 分類したディレクトリに対応するように設定ファイル(drizzle.config.ts)を変更する
  • ローカルのディレクトリ分はgit管理下から外す(ignoreに記載する)
  • CIを開発環境用、本番環境用に作成する

seedデータ投入の仕組みは...ない?

調べたところ、DrizzleORMにはseedデータ投入の仕組みはないようでした。独自にデータ投入用のフローを作る必要がありそうです。

https://zenn.dev/steg/articles/77204b889814d1

上記の記事を参考に、考えた仕組みは以下の通りです。

  • drizzleORMのクエリを使って、データ投入を行うファイルを作成する
  • docker起動時にコマンドでファイル実行する

たとえば以下のようにseedファイルを生成して、package.jsonにコマンド記述、Dockerfile.devにコマンド追加をします。
※データ適当ですみません

front/app/db/seed.ts
import db, { connection } from ".";
import { users } from "./schema";

const userList = [
  [1, 'ユーザー1'],
  [2, 'ユーザー2'],
  [3, 'ユーザー3'],
  [4, 'ユーザー4']
];

for (const [id, name] of userList) {
  await db.insert(users)
    .values({
      id: Number(id),
      name: name.toString()
    })
    .onDuplicateKeyUpdate({
      set: {
        name: name.toString()
      }
    });
}

await connection.end();

onDuplicateKeyUpdate は、MySQLの「INSERT ... ON DUPLICATE KEY UPDATE Statement」を使っており、データが既に存在していたらnameの更新を行うSQL生成をしています。

https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html

package.jsonにコマンド設定と、Dockerコンテナ起動時にコマンドセット
package.json
// 以下scriptを追加
"drizzle:seed": "tsx ./app/db/seed.ts"
Dockerfile.dev
# CMDに追加
CMD ["sh", "-c", "npm install && npm run drizzle:generate && npm run drizzle:migrate && npm run drizzle:seed && npm run dev"]

まとめ

TypeScriptを使った開発が多くなっている今、DrizzleはTypeScriptを前提としたORMなのでプロジェクトに導入しやすい。シンプルで軽量で、SQLがわかっていれば学習コストも低く、新規開発をする際に選択肢として上がってきて良さそうなORMだと感じています。

ただ比較的新しいORMであるため、参考情報が少なかったりエコシステムが不十分であったりする部分もあるので、他のORMとメリットデメリットを比較検討してみることをおすすめします。

DrizzleORMは軽量で高速なパフォーマンス、TypeScript前提であるため堅安全性がある、導入しやすくてシンプルなORMです。

個人開発などでも使いやすいと思うのでぜひ使ってみてください!

以上、長文をここまで読んでくださりありがとうございました。

参考資料

https://orm.drizzle.team/docs/get-started-mysql#mysql-2

https://www.npmjs.com/package/dotenv

https://github.com/drizzle-team/drizzle-orm/discussions/1104

https://zenn.dev/steg/articles/77204b889814d1

https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html

toridori tech blog

Discussion

ボーノボーノ

有益な情報ありがとうございました!

onDuplicateKeyUpdate は、... データが既に存在していたら更新をしないようにするためのSQL生成をしています。

データが既に存在していたらnameの更新を行う、が正しいかと思いました。

一つ質問させてください。

npm run drizzle:seed

を実行したところ以下のエラーが発生しました。

app/db/index.ts:8:26: ERROR: Top-level await is currently not supported with the "cjs" output format

この記事と全く同じ手順で進めたのですが・・・。
かれこれ5時間くらいハマっていまして 😢 何かアドバイスいただけないでしょうか。

package.jsonに "type": "module" を追加してから npm run drizzle:seed を実行するとうまくいくのですが、next.jsで作ったページを閲覧するとエラーとなるためそれは避けたく。

すみませんがよろしくお願いいたします 🙇

matsuda_tmatsuda_t

ボーノさん

コメントいただきありがとうございます!
返答が大変遅くなってしまい申し訳ありません。

データが既に存在していたらnameの更新を行う、が正しいかと思いました。

こちら修正いたしました!
seedのエラー対処に関しましては、現状分かりかねているため少し調査してみます。
念のため使用されているTypeScriptのバージョンを伺いたいです!