🌧️

最近注目のDrizzle ORMをNext.jsで使ってみた

2023/10/12に公開

はじめに

drizzleというTypeScriptのORMが良さそうなので触ってみました!
ORMのスキーマを定義することで、zodのバリデーションスキーマが生成される点が気に入っています。

Git Hubのスターも今年の4月頃から伸びています。

Star History Chart

今回は、Drizzleを使ってマイグレーションを行い、Next.jsのRoute HandlersでCRUDのREST APIを作成するところまでの記事を作成します。

また、今回の記事では、Drizzleのマイグレーションツールを使いますが、
別の方法として、マイグレーションをDrizzleではないツールを使い、
DBの情報をもとにDrizzleのスキーマファイルを生成することもできます。

データベースはPostgresです。

ソースコード

https://github.com/ao-39/next_drizzle

マイグレーション

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
./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
./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の接続情報を環境変数に追加

.env
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres

マイグレーション用スクリプトの追加

マイグレーションを行うためのスクリプトを作成します。
マイグレーションを行う際は、ts-nodeを使って以下のスクリプトを実行します。

ソースコード ./db/migrate.ts
./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.jsonscriptsに以下のコマンドを追加します。

package.json
"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を変更

compilerOptionstargetes6に変更します。

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

以下のコマンドでマイグレーションファイル(SQL)を生成します。

npm run generate-migration

マイグレーションファイルの編集

レコードをアップデートした際にupdate_atの値が自動で変わるようにトリガーを追加します。

ソースコード ./db/migrations/生成されたマイグレーションファイル名

BEGEINから下が追加したトリガーです。

./db/migrations/生成されたマイグレーションファイル名
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
./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
./app/api/v1/users/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でも実装してみたいと思いました。

コラボスタイル Developers

Discussion