2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Drizzleを使用したマイグレーション & DB操作 + drizzle-orm/zodによるバリデーション

2
Posted at

はじめに

Next.jsの学習の中でORMを調べていて、Prisma以外のORMの選択肢は無いか...というところでDrizzleが気になり、実際に触ってみたところ便利だったので備忘録も兼ねて使用方法をまとめてみました。

スキーマ定義からマイグレーション生成、CRUD実装、Zodによるフォーム検証までの流れを、触った範囲で記載しています。

簡単なCRUDアプリを想定しての内容で、前提となる構成は次のとおりです。

  • Next.js
    • フォームデータの取得・登録はServer Actionで実施
  • DB: Turso(SQLite)
  • ORM: drizzle-orm / drizzle-kit
  • バリデーション: zod(drizzle-kit/zodを使用)

インストール

SQLiteを前提としたインストール手順はDrizzle公式のドキュメントでも記載がある通り、ORM本体の他にlibsqlをインストールします。

npm i drizzle-orm@rc @libsql/client zod
npm i -D drizzle-kit@rc

Zod連携用のAPIは、別パッケージではなくdrizzle-orm/zodからインポートします。本稿のコード例は drizzle-orm@rc と Zod 4 を前提にしています。

マイグレーションファイル作成

スキーマを先に書く

Drizzleではschema.tsを正として扱います。

テーブル定義をTypeScriptで記述するというのがPrismaと異なる点で、そこからSQLマイグレーションを生成する流れです。

ここでは簡単な買い物記録アプリを想定し、id, 名前, 金額, 購入日, 備考, 作成日, 更新日のテーブルを例として記載しています。

import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const shoppingsTable = sqliteTable("shoppings", {
  id: integer("id").primaryKey(),
  name: text("name").notNull(),
  amount: integer("amount").notNull(),
  date: text("date").notNull(),
  description: text("description"),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .default(sql`(CURRENT_TIMESTAMP)`),
  updatedAt: integer("updated_at", { mode: "timestamp" })
    .notNull()
    .default(sql`(CURRENT_TIMESTAMP)`)
    .$onUpdate(() => new Date()),
});

ここで便利なのが、スキーマの内容をそのまま型としてexportできるということです。

export type InsertShopping = typeof shoppingsTable.$inferInsert;
export type SelectShopping = typeof shoppingsTable.$inferSelect;

$inferInsertはDBへ書き込む型、$inferSelectはDBから読む型です。

Server Actionの引数やクエリ結果の型付けに使うことができ、コンパイル時の型の一致を保証できます。

一方、フォーム入力のランタイム検証は後述のdrizzle-orm/zod(createInsertSchemaなど)でスキーマからZodを生成して行います。型は$infer*、実行時の検証はdrizzle-orm/zod、と役割を分けるイメージです。

configファイルの作成

スキーマファイルの場所やマイグレーションファイルの吐き出し先は、drizzle.config.tsで定義します。

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

export default defineConfig({
  schema: "./lib/db/schema.ts",
  out: "./migrations",
  dialect: "turso", // ローカル SQLite なら sqlite
  dbCredentials: {
    url: process.env.TURSO_CONNECTION_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!,
  },
});

マイグレーションファイルの生成と適用

スキーマとconfigを整備したら、マイグレーションを実行します。
ここで重要な点として、drizzleでは2種類のマイグレーション方法が用意されています。

push
DBに直接スキーマ情報を反映する方法

npx drizzle-kit push ## DBに直接反映

generate + migrate
一旦SQLファイルを出力してから、それを実行してDBに反映する方法

npx drizzle-kit generate # SQLを出力
npx drizzle-kit migrate #dbに適用

ローカルで試す段階、または個人開発であれば push が手早いかと思います。

一方、複数の本番運用など、変更履歴を残したい・複数環境で同じ手順を踏みたいケースではgenerate + migrateによる運用がベストプラクティスとされているようでした。

注意点として、同じデータベースに対して push と migrate を混在させると、マイグレーション履歴と実DBの状態がずれて適用に失敗しやすくなってしまうため、どちらかに統一するのが良さそうです。

DrizzleによるCRUD

CRUDには、クライアントを定義してそれを使用します。

ts index.ts
import { drizzle } from "drizzle-orm/libsql";

export const db = drizzle({
  connection: {
    url: process.env.TURSO_CONNECTION_URL!,
    authToken: process.env.TURSO_AUTH_TOKEN!,
  },
});

Create

import type { InsertShopping } from "./schema";

const createShopping = async (shopping: InsertShopping) => {
  await db.insert(shoppingsTable).values(shopping);
};

await createShopping({
  name: "...",
  amount: 12000,
  date: "2026-09-17",
  description: "...",
});

処理を関数に切り出し、引数の型をInsertShoppingにすると、必須カラムの抜けをTypeScriptが検出できます。idやcreatedAtなどdefault付きのカラムは省略して問題ありません。

Read

import { between, desc, eq } from "drizzle-orm";

const response = await db
  .select({
    id: shoppingsTable.id,
    date: shoppingsTable.date,
    amount: shoppingsTable.amount,
    name: shoppingsTable.name,
  })
  .from(shoppingsTable)
  .where(between(shoppingsTable.date, startDate, endDate))
  .orderBy(desc(shoppingsTable.date));

上記のように、SQLに近い形式でselectできます。ここでは購入日をYYYY-MM-DDの文字列で持っているため、betweenによる日付レンジ指定を条件の一例として使っています。

Update

await db
  .update(shoppingsTable)
  .set({ name, amount, date, description })
  .where(eq(shoppingsTable.id, parseInt(id)));

updatedAtはschemaの$onUpdateに任せる方法と、明示的にセットする方法があります。

Delete

await db.delete(shoppingsTable).where(eq(shoppingsTable.id, id));

DrizzleスキーマからZodを自動生成する

DBにデータを登録するにあたって、バリデーションが必要になってきます。

ここで効くのが、Drizzleのテーブル定義からZodスキーマを生成できる点です。DBスキーマを正にしつつ、同じ定義を検証にも使えます。

createInsertSchemaで生成する

drizzle-orm/zodのcreateInsertSchemaにテーブルを渡すと、insert用のZodスキーマが得られます。同様にcreateSelectSchema、createUpdateSchemaもあります。

import { createInsertSchema } from "drizzle-orm/zod";

export const shoppingInsertSchema = createInsertSchema(shoppingsTable);

NOT NULLは必須、nullableは任意、default付きカラムは省略可能、といった対応がテーブル定義から引き継がれます。手でz.objectを書き直す必要がなく、schema.tsの変更にも追従しやすいです。

生成したスキーマは、そのままparseやsafeParseに使えます。

const parsed = shoppingInsertSchema.parse({
  name: "コーヒーメーカー",
  amount: 12000,
  date: "2026-09-17",
  description: "メモ",
});

await db.insert(shoppingsTable).values(parsed);

フォーム向けに調整する

フォーム入力では、DBカラムすべてが必要とは限りません。また、文字数上限や日本語メッセージなど、DBに載せない制約を足したくなることもあります。

第2引数のrefinementでフィールドを拡張・上書きできます。また、フォームで扱うカラムだけに絞るなら、生成後にpickします。

import { createSchemaFactory } from "drizzle-orm/zod";

// フォームからの数値は文字列になりやすいので、coerceを有効にする
const { createInsertSchema } = createSchemaFactory({
  coerce: { number: true },
});

export const shoppingFormSchema = createInsertSchema(shoppingsTable, {
  name: (schema) =>
    schema
      .min(1, { message: "名前を入力してください" })
      .max(50, { message: "名前は50文字以内にしてください" }),
  amount: (schema) =>
    schema
      .min(1, { message: "金額を入力してください" })
      .max(1_000_000_000, { message: "金額は10億円以内にしてください" }),
  date: (schema) =>
    schema.regex(/^\d{4}-\d{2}-\d{2}$/, {
      message: "日付はYYYY-MM-DD形式にしてください",
    }),
  description: (schema) =>
    schema.max(500, { message: "説明は500文字以内にしてください" }),
}).pick({
  name: true,
  amount: true,
  date: true,
  description: true,
});

createSchemaFactory({ coerce: { number: true } })を使うと、"12000"のような文字列をnumberへ変換できるためServer Actionと相性がよいです。

FormDataからsafeParseへ

作成したschemaを使った実際のバリデーション関数は以下のように書けます。

export function parseShoppingForm(formData: FormData) {
  return shoppingFormSchema.safeParse({
    name: formData.get("name"),
    amount: formData.get("amount"),
    date: formData.get("date"),
    description: formData.get("description"),
  });
}

これを使用した実際のzodバリデーション処理を書くと以下の通りです。

import { z } from "zod";

const validatedFields = parseShoppingForm(formData);

if (!validatedFields.success) {
  const fieldErrors = z.flattenError(validatedFields.error).fieldErrors;
  return {
    status: "error",
    message: "入力内容を確認してください",
    errors: fieldErrors,
  };
}

await db.insert(shoppingsTable).values(validatedFields.data);

Zodを通すとvalidatedFields.dataの型が確定し、ほぼそのままdb.insertに渡すことができます。

失敗時はfieldErrorsを返し、その値を使用して入力欄の下に表示できます。登録と編集で同じフォーム用スキーマを共有できるという点も便利です。

おわりに

  • schema.tsを正にしてマイグレーションを作成
  • 入力検証はdrizzle-orm/zodでスキーマから生成
  • CRUDをDrizzleのクエリビルダで記載

というDrizzleを使用した一連の流れを記載しました。

便利だったのは、スキーマを定義すればそれを正としてマイグレーション、バリデーション、CRUDとDB周りで一貫した型の整合性を担保できるという点でした。

開発をするにあたって、AIが生成したコードの間違いを検知しやすいのではという期待もあり、TypeScriptで開発をする際はかなり有力な選択なのではと思いました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?