最近注目のDrizzle ORMをNext.jsで使ってみた
はじめに
drizzleというTypeScriptのORMが良さそうなので触ってみました!
ORMのスキーマを定義することで、zodのバリデーションスキーマが生成される点が気に入っています。
Git Hubのスターも今年の4月頃から伸びています。
今回は、Drizzleを使ってマイグレーションを行い、Next.jsのRoute HandlersでCRUDのREST APIを作成するところまでの記事を作成します。
また、今回の記事では、Drizzleのマイグレーションツールを使いますが、
別の方法として、マイグレーションをDrizzleではないツールを使い、
DBの情報をもとにDrizzleのスキーマファイルを生成することもできます。
データベースはPostgresです。
ソースコード
マイグレーション
Next.jsひな形作成
Next.jsのプロジェクトがなければ作成してください。
npx create-next-app@latest dotenv
ライブラリのインストール
npm i drizzle-orm pg dotenv drizzle-zod
npm i -D drizzle-kit ts-node @types/pg
スキーマ定義
詳しくはドキュメントを参照してください。
ソースコード ./db/schema.ts
import { pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").defaultRandom().notNull().primaryKey(),
name: text("name").notNull(),
discriminator: varchar("discriminator", { length: 255 }).notNull().unique(),
email: varchar("email", { length: 255 }).notNull().unique(),
note: text("note").default("").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
今回はこの./db/schema.ts
を元にDBにスキーマをマイグレーションしますが、
Drizzleでは、DBから./db/schema.ts
を生成することもできます。
DBクライアントの定義
こちらで作成するDBのクライアントをインポートして、DBにアクセスしていきます。
ソースコード ./db/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as dotenv from "dotenv";
import * as schema from "./schema";
dotenv.config();
export const DatabaseError = pg.DatabaseError;
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { logger: true, schema });
DBの接続情報を環境変数に追加
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
マイグレーション用スクリプトの追加
マイグレーションを行うためのスクリプトを作成します。
マイグレーションを行う際は、ts-node
を使って以下のスクリプトを実行します。
ソースコード ./db/migrate.ts
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db } from "./db";
// this will automatically run needed migrations on the database
migrate(db, { migrationsFolder: "./db/migrations" })
.then(() => {
console.log("Migrations complete!");
process.exit(0);
})
.catch((err) => {
console.error("Migrations failed!", err);
process.exit(1);
});
package.jsonにコマンドの追加
package.json
のscripts
に以下のコマンドを追加します。
"generate-migration": "drizzle-kit generate:pg --out db/migrations --schema db/schema.ts",
"migrate": "ts-node -P db/db.tsconfig.json db/migrate"
tsconfig.jsonを変更
compilerOptions
のtarget
をes6
に変更します。
マイグレーションファイルの生成
以下のコマンドでマイグレーションファイル(SQL)を生成します。
npm run generate-migration
マイグレーションファイルの編集
レコードをアップデートした際にupdate_at
の値が自動で変わるようにトリガーを追加します。
ソースコード ./db/migrations/生成されたマイグレーションファイル名
BEGEINから下が追加したトリガーです。
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"discriminator" varchar(255) NOT NULL,
"email" varchar(255) NOT NULL,
"note" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_discriminator_unique" UNIQUE("discriminator"),
CONSTRAINT "users_email_unique" UNIQUE("email")
);
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_trigger BEFORE UPDATE ON users FOR EACH ROW EXECUTE PROCEDURE set_updated_at();
マイグレーションの実行
npm run migrate
以上でマイグレーション完了です!
今後スキーマを変更する際は、./db/schema.ts
を編集してマイグレーションファイルの生成、マイグレーションの実行の流れで実施します。
api実装
マイグレーションが終わりましたので、CRUDを実装していきましょう✨
Route Handlersを使ってREST APIのみ実装していきます。
実装するAPI
- POST /users
- GET /users/{discriminator}
- PATCH /users/{discriminator}
- DELETE /users/{discriminator}
Insert POST /users
ソースコード ./app/api/v1/users/route.ts
import { db, DatabaseError } from "@/db/db";
import { users } from "@/db/schema";
import { createSelectSchema } from "drizzle-zod";
import { createInsertSchema } from "drizzle-zod";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
export const userInsertReqSchema = createInsertSchema(users, {
name: z.string().min(1).max(255),
email: z.string().email(),
discriminator: z
.string()
.regex(/^[A-Za-z0-9_]+$/)
.min(3)
.max(255),
}).pick({
name: true,
email: true,
discriminator: true,
});
type UserInsertReqSchema = z.infer<typeof userInsertReqSchema>;
export const userInsertResSchema = createSelectSchema(users).pick({
name: true,
email: true,
discriminator: true,
});
type UserInsertReturn = z.infer<typeof userInsertResSchema>;
type UserInsertRes =
| {
data: UserInsertReturn;
}
| {
validateError?: z.ZodError<UserInsertReqSchema>;
constraintError?: "users_discriminator_unique" | "users_email_unique";
error?: "internal error";
};
export async function POST(
req: NextRequest
): Promise<NextResponse<UserInsertRes>> {
// bodyのバリデーション
const insertUserResult = userInsertReqSchema.safeParse(await req.json());
if (!insertUserResult.success) {
return NextResponse.json(
{ validateError: insertUserResult.error },
{
status: 400,
}
);
}
try {
// insertとselectを行う
const usersRes = await db
.insert(users)
.values(insertUserResult.data)
.returning({
name: users.name,
email: users.email,
discriminator: users.discriminator,
});
return NextResponse.json(
{ data: usersRes[0] },
{
status: 201,
}
);
} catch (e) {
// 一意制約違反の場合は409を返す
if (e instanceof DatabaseError) {
if (
e.constraint === "users_discriminator_unique" ||
e.constraint === "users_email_unique"
) {
return NextResponse.json(
{ constraintError: e.constraint },
{
status: 409,
}
);
}
}
// それ以外のエラーは500を返す
return NextResponse.json(
{ error: "internal error" },
{
status: 500,
}
);
}
}
Select GET /users/{discriminator}
ソースコード ./app/api/v1/users/[discriminator]/route.ts
import { db } from "@/db/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { createSelectSchema } from "drizzle-zod";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
export const userSelectResSchema = createSelectSchema(users, {
name: z.string().min(1).max(255),
email: z.string().email(),
discriminator: z
.string()
.regex(/^[A-Za-z0-9_]+$/)
.min(3)
.max(255),
}).pick({
name: true,
discriminator: true,
});
export type UserSelectResSchema = z.infer<typeof userSelectResSchema>;
type UserSelectRes =
| { data: UserSelectResSchema }
| {
error?: "not found";
};
export async function GET(
request: NextRequest,
{
params,
}: {
params: { discriminator: string };
}
): Promise<NextResponse<UserSelectRes>> {
const selectedUser = await db
.select({
name: users.name,
discriminator: users.discriminator,
})
.from(users)
.where(eq(users.discriminator, params.discriminator));
if (selectedUser.length !== 1) {
return NextResponse.json(
{
error: "not found",
},
{
status: 404,
}
);
}
return NextResponse.json({ data: selectedUser[0] });
}
Update PATCH /users/{discriminator}
ソースコード ./app/api/v1/users/[discriminator]/route.ts
export const userInsertReqSchema = createInsertSchema(users, {
name: z.string().min(1).max(255),
email: z.string().email(),
discriminator: z
.string()
.regex(/^[A-Za-z0-9_]+$/)
.min(3)
.max(255),
})
.pick({
name: true,
email: true,
discriminator: true,
note: true,
})
.partial()
.refine((val) => Object.keys(val).length >= 1, {
message: "at least one field is required",
});
type UserUpdateReqType = z.infer<typeof userInsertReqSchema>;
export async function PATCH(
request: NextRequest,
{
params,
}: {
params: { discriminator: string };
}
): Promise<
NextResponse<null | {
validateError?: z.ZodError<UserUpdateReqType>;
constraintError?: "users_discriminator_unique" | "users_email_unique";
error?: "not found" | "internal error";
}>
> {
const validUpdateUser = userInsertReqSchema.safeParse(await request.json());
if (validUpdateUser.success === false) {
return NextResponse.json(
{
validateError: validUpdateUser.error,
},
{
status: 400,
}
);
}
try {
const res = await db
.update(users)
.set(validUpdateUser.data)
.where(eq(users.discriminator, params.discriminator));
console.log(res);
if (res.rowCount === 0) {
return NextResponse.json(
{
error: "not found",
},
{
status: 404,
}
);
}
} catch (e) {
// 一意制約違反の場合は409を返す
if (e instanceof DatabaseError) {
if (
e.constraint === "users_discriminator_unique" ||
e.constraint === "users_email_unique"
) {
return NextResponse.json(
{ constraintError: e.constraint },
{
status: 409,
}
);
}
}
// それ以外のエラーは500を返す
return NextResponse.json(
{ error: "internal error" },
{
status: 500,
}
);
}
return new NextResponse(null, {
status: 204,
});
}
Delete DELETE /users/{discriminator}
ソースコード ./app/api/v1/users/[discriminator]/route.ts
export async function DELETE(
request: NextRequest,
{
params,
}: {
params: { discriminator: string };
}
): Promise<NextResponse<null | { error?: "not found" | "internal error" }>> {
try {
const res = await db
.delete(users)
.where(eq(users.discriminator, params.discriminator));
if (res.rowCount === 0) {
return NextResponse.json(
{
error: "not found",
},
{
status: 404,
}
);
}
} catch (e) {
// それ以外のエラーは500を返す
return NextResponse.json(
{ error: "internal error" },
{
status: 500,
}
);
}
return new NextResponse(null, { status: 204 });
}
おわりに
SQLライクなメソッド名なところが、使いやすいなと感じました。
Zodのスキーマを生成して使いまわせるのも良かったです。
データベース回りのエラーハンドリングの方法に少し悩みました。ORMでuniqueキーエラーをもっとうまくハンドリングできるとうれしいなと思いました。返り値でエラーを伝えてくれればいいのですが、、、
CRUDのシンプルなクエリをだけでしたが、今後複雑な使い方をしたら記事にしたいと思います。
まだexperimentalになっていますが、ServerActionsでも実装してみたいと思いました。
Discussion