Open4

Drizzleってみる。

Mt.SouthernMt.Southern

フロントエンドのORMといえばprismaがいっぱいヒットするけど、ひねくれもんなのでDrizzle ORMをいじくってみることにする。

決して名前の響きで選んだわけではない。

と言うことで早速、セットアップから。

元はこのあいだ作ったプロジェクトをベースにしてみる。

相変わらずbun使うことにする。(だって早いし)

https://gitlab.com/mt.southern/i18n-next.git

git clone https://gitlab.com/mt.southern/i18n-next.git drizzle-app
cd drizzle-app
bun install

つらつら、パッケージとか取りに行くので

401 packages installed [15.41s]

とか出たら、次に

bun run dev

でとりあえず動くか確認。確認できたらいよいよdrizzleのセットアップ、とその前にDBはpostgreSQLでいきます。

ただ、ローカルにセットアップするのも億劫なので、supabaseを使うことにする。

こっちのセットアップ等は既に終わっているので割愛。

ではでは、Drizzle ORMのセットアップを行う。ついでにdrizzle-zodとかも入れてみる。

bun add drizzle-orm drizzle-zod postgres
bun add -D drizzle-kit @types/pg 

まずはテーブル定義用のファイルを作成する。

mkdir db
touch db/schema.ts

db/schema.ts

db/schemas.ts
import { pgTable, uuid, text, varchar, timestamp } from "drizzle-orm/pg-core";
 
export const profile = pgTable('profiles', {
  id: uuid('id').defaultRandom().primaryKey().notNull(),
  fullName: text('full_name'),
  phone: varchar('phone', { length: 256 }),
  createdAt:  timestamp('created_at', { withTimezone: true, mode: 'string' }).defaultNow()
});

続いて、.env.localファイルと接続用のデータベースオブジェクトを作る

touch db/index.ts
touch .env.local

DB_URLはsupabaseの
Dashboard > Settings > Database
のConnection StringのURIからコピペする。

passwordはsupabaseのプロジェクト作成時に設定したもの。

.env.local
DB_URL=postgresql://postgres:[YOUR-PASSWORD]@db.[project-id].supabase.co:5432/postgres
db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import * as schema from "@/db/schema";

const connectionString = process.env.DB_URL!;
const client = postgres(connectionString, { max: 1 });

export const db = drizzle(client, { schema });

export default db;

awaitをトップレベルでやると怒られたのでtsconfig.jsonのtargetを"es5"から"ESNext"に変更。

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": [

次は drizzle.config.tsを作成。
それから、drizzleのmigrationファイルとかを格納するディレクトリを作成。

touch drizzle.config.ts
mkdir drizzle
drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: "./db/schema.ts",
  out: "./drizzle",
  driver: "pg",
  dbCredentials: {
    connectionString: process.env.DB_URL!,
  },
} satisfies Config;

あ、ここでdriverにpgを指定しているのでもってこないとね〜。

bun add pg

主な設定を終わったので後は、マイグレーションコマンドをpackage.jsonにscriptとして登録するだけ。

package.json
{
  "name": "i18n-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "migrate": "drizzle-kit generate:pg --config=drizzle.config.ts",
    "intropect": "drizzle-kit intropect:pg --config=drizzle.config.ts",
    "push": "drizzle-kit push:pg --config=drizzle.config.ts",
    "drop": "drizzle-kit drop --config=drizzle.config.ts"
  },

早速、マイグレーションコマンドを叩く。

bun run migrate

とすると、こんな感じの実行結果となる。

app@59fbc33877b2:/app/drizzle-app$ bun run migrate
$ drizzle-kit generate:pg --config=drizzle.config.ts
drizzle-kit: v0.20.2
drizzle-orm: v0.29.0

Reading config file '/app/drizzle-app/drizzle.config.ts'
1 tables
profiles 7 columns 0 indexes 0 fks

[] Your SQL migration file ➜ drizzle/0000_complete_nightmare.sql 🚀

このあと

bun run push

とすると

app@59fbc33877b2:/app/drizzle-app$ bun run push
$ drizzle-kit push:pg --config=drizzle.config.ts
drizzle-kit: v0.20.2
drizzle-orm: v0.29.0

Custom config path was provided, using 'drizzle.config.ts'
Reading config file '/app/drizzle-app/drizzle.config.ts'
[] Changes applied

となったので、supabase 覗くとこうなってた。

よしよし、ちゃんとできてるね。

てことで、実際にテーブルにデータをインサートして確認してみる。

app/[lng]/page.tsx 修正

app/[lng]/page.tsx
import React from "react";
import Link from "next/link";
import { useTranslation } from "./i18n";
import { profile } from "@/db/schema";
import db from "@/db";
import { eq } from "drizzle-orm";

type NewProfile = typeof profile.$inferInsert;

const addProfile: NewProfile = {
  name: "Alice",
  email: "alice@example.com",
  password: "pass",
  role: "ADMIN",
};
const insertProfile = async (p: NewProfile) => {
  const data = db
    .select()
    .from(profile)
    .where(eq(profile.email, p.email));
  if (data) return;
  return db.insert(profile).values(p).returning();
};

const HomePage = async ({ params }: { params: { lng: string } }) => {
  const { t } = await useTranslation(params.lng);
  const created = await insertProfile(addProfile);
  console.log(created);
  return (
    <>
      <h1>{t("home.title")}</h1>
      <Link href={`/${params.lng}/about`}>{t("home.link.about")}</Link>
    </>
  );
};
export default HomePage;

ブラウザからアクセスしてみたら、

コンソール抜粋
[
  {
    id: 'db6eee66-4f9a-4d24-b736-81798c298a88',
    name: 'Alice',
    email: 'alice@example.com',
    password: 'pass',
    role: 'ADMIN',
    createdAt: 2023-11-17T08:21:46.998Z,
    updatedAt: null
  }
]

と表示されていてしっかりテーブルにインサートできた!

Mt.SouthernMt.Southern

とりえずDBとの連携はうまくいったので、次はDrizzleのお作法、特に複数テーブルでリレーションとかみてみる。

このへん見た感じだと、SQLに近い形でクエリを書けるだけでなくて、prismaみたいに、db.query.profile.findManyとかもいけるらしい。んで、結果が入れ子になる様なのもできるっぽい。

コード例
const result = await db.query.users.findMany({
	with: {
		posts: true			
	},
});
結果例
[{
	id: 10,
	name: "Dan",
	posts: [
		{
			id: 1,
			content: "SQL is awesome",
			authorId: 10,
		},
		{
			id: 2,
			content: "But check relational queries",
			authorId: 10,
		}
	]
}]

でそれには、スキーマ間でリレーションを張ってあげるとできるっぽい。

こんな感じ。

schema.ts例
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});
 
export const usersRelations = relations(users, ({ many }) => ({
	posts: many(posts),
}));
 
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id'),
});
 
export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
	}),
}));

ここらへん。これは、users:postsが1:nのパターン。

抜粋
export const usersRelations = relations(users, ({ many }) => ({
	posts: many(posts),
}));export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
	}),
}));

relationで関連作る場合は、posts.autherIdにreferences付けへんのなんでかな??
参照整合性付けたくないってだけ?

ふつうは、

postsスキーマ、外部キー参照先を付けた例
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id').references(() => (users.id)),
});
postsスキーマ、外部キー参照先を付けた例(更にonDeleteにcascadeした例)
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id')
            .references(() => (users.id), , { onDelete: "cascade" }),
});

な感じか。

Mt.SouthernMt.Southern

リレーションシップの補足。対1側(one)は必ず、元テーブルのフィールドと参照先テーブルのフィールドを明記するので良いが、対多側(many)は、エンティティ名しか書いてなかったりする。

公式のこの辺読むと、同一テーブル間に複数の関連があるとどれとどれをつなぐのかわからんようになるので、名前でうまくつなぐ必要がある。

usersテーブル
export const users = pgTable('users', {
	id: serial('id').primaryKey(),
	name: text('name'),
});
postsテーブル
export const posts = pgTable('posts', {
	id: serial('id').primaryKey(),
	content: text('content'),
	authorId: integer('author_id'),
	reviewerId: integer('reviewer_id'),
});

この二つのテーブルのリレーションを

リレーション
export const usersRelations = relations(users, ({ many }) => ({
	author: many(posts, { relationName: 'author' }),
	reviewer: many(posts, { relationName: 'reviewer' }),
}));

export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
		relationName: 'author',
	}),
	reviewer: one(users, {
		fields: [posts.reviewerId],
		references: [users.id],
		relationName: 'reviewer',
	}),
}));

こんな感じで、

author: many(posts, { relationName: 'author' }),
	author: one(users, {
		fields: [posts.authorId],
		references: [users.id],
		relationName: 'author',
	}),

relationNameに同じ定義名にしてあげるとこれを元にリレーションを張ることになる。

この設定をして、

実行例
const users = await db.query.users.findMany({
	with: {
		posts: {
			with: {
				comments: true,
			},
		},
	},
});

とやると、usersの中にpostsが入れ子になり、更にpostsからcommentsが入れ子になる。
(commentsのスキーマとリレーションは定義しているとして)

Mt.SouthernMt.Southern

ではでは、本格的にschema.ts書いてみるかな〜。

その前に、前に作ったテーブルとかschema.tsは破棄して新たに作成する。

bun drop

ってやると自動生成されたsqlファイル名が表示されて、選択待ちになるので前回作成文は削除しとく。

さて、準備も整ったので、schemaファイルに改めて、テーブルの定義を記述する。

同時にdrizzle-zodでのバリデーション定義もしておく。

db/schema.ts
import { relations } from "drizzle-orm";
import {
  pgTable,
  bigserial,
  text,
  varchar,
  timestamp,
  bigint,
  boolean,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";

const validRoles = ["ADMIN", "MANAGER", "USER", ""] as const;
type RoleType = (typeof validRoles)[number];

export const profile = pgTable("profiles", {
  id: bigserial("id", { mode: "number" }).primaryKey().notNull(),
  name: varchar("name", { length: 100 }).notNull(),
  email: varchar("email", { length: 100 }).notNull().unique(),
  password: varchar("password", { length: 64 }).notNull(),
  role: varchar("role", { length: 50 }).$type<RoleType>().notNull(),
  active: boolean("active").default(true),
  createdAt: timestamp("created_at", {
    withTimezone: true,
    mode: "string",
  }).defaultNow(),
  updatedAt: timestamp("updated_at", {
    withTimezone: true,
    mode: "string",
  }).defaultNow(),
});

const PASSWORD_RULE =
  /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&-_+])[A-Za-z\d@$!%*?&-_+]{8,}$/;

export const insertProfileSchema = createInsertSchema(profile, {
  name: z.string().min(1).max(100),
  email: z.string().email().max(100),
  password: z.string().regex(PASSWORD_RULE).max(50),
  role: z.enum(validRoles),
});
export const selectProfileSchema = createSelectSchema(profile, {
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().max(100).optional(),
  password: z.string().regex(PASSWORD_RULE).max(50).optional(),
  role: z.enum(validRoles).optional(),
});

drizzle-zodのcreateInsertSchemaやcreateSelectSchemaではデフォルトで最低限のバリデーションルールは付与してくれるが、全然足りないので再定義する。

つぎはサービスモジュールを作る。本来はもう少し分けてDBアクセスはリポジトリとかにレイヤを分けた方がとも思ったけど、めんどいのでいったんここでDBアクセスもしてしまう。

気が向いたらそのうちリファクタするかな。

app/_services/ProfileService.ts
import db from "@/db";
import { profile, insertProfileSchema } from "@/db/schema";
import { eq } from "drizzle-orm";

export type { Profile, ProfileParam as ProfileInsert };
export {
  findAllActiveProfiles
};

type Profile = typeof profile.$inferSelect;
type ProfileParam = typeof profile.$inferInsert;

const findAllActiveProfiles = async (): Promise<Profile[]> => {
  const profiles = await db.query.profile.findMany({
    where: eq(profile.active, true),
  });
  return profiles;
};

APIにして、APIから関数呼び出ししてみる。

app/api/profiles/route.ts
export const GET = async (req: NextRequest) => {
  const profileList: Profile[] = await findAllActiveProfiles();
  return NextResponse.json(profileList);
 };