drizzle ormのメモ
Drizzleを設定した
dev環境ではPGliteを使用
本番環境ではほかのpostgresqlサービスを使うかもしれない
環境で変わりそうなところを.envに設定
DATABASE_URL="./data"
DRIVER="pglite"
設定ファイルは以下
import { defineConfig } from 'drizzle-kit';
import { env } from 'std-env';
const { DATABASE_URL, DRIVER } = env;
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is not set');
}
const config = {
// migrationファイルの出力先
// pushだと使わないしdrizzleは初期値なのでここに書く意味は薄いな…
out: './drizzle',
// schemaファイル これは単ファイルの場合の書き方
schema: './schema.ts',
// 接続先 ローカルのpgliteなら保存先ファイル
dbCredentials: {
url: DATABASE_URL,
},
// push時に実行を確認
strict: true,
// push時にSQLを表示
verbose: true,
// postgresqlを使用
dialect: 'postgresql' as const,
// DBカラム名をsnake_caseにする 重要
casing: 'snake_case' as const,
// pgliteを使用
driver: DRIVER,
};
// 変数に入れないで直接defineConfig()に渡せば良いのだけど
// console.logしたかったので一旦変数化した
console.log({ userDB: config });
export default defineConfig(config);
dbのエントリーポイント
import { PGlite } from '@electric-sql/pglite';
import { resolve } from '@std/path/resolve';
import { config } from 'dotenv';
import { drizzle } from 'drizzle-orm/pglite';
import { env } from 'std-env';
import * as schema from './schema';
// なんかstd-envだけではmonorepoの相対パスのenvの取得が上手くいかなかった
config({ path: resolve(import.meta.dirname, '.env') });
// DATABASE_URLの取得はdrizzle.config.tsと同じ
const { DATABASE_URL } = env;
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is not set');
}
// ローカルのurlを相対パスに変換
const url = DATABASE_URL.startsWith('.')
? resolve(import.meta.dirname, './data')
: DATABASE_URL;
console.log(`DB URL: ${url}`);
// オプションの適用
const client = new PGlite(url);
const casing = 'snake_case';
const logger = true;
export const db = drizzle({ schema, client, casing, logger });
idにはcuid2を採用 固定長なので型はchar()でよかろう
import { createId } from '@paralleldrive/cuid2';
import { relations } from 'drizzle-orm';
import { char, integer, pgEnum, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
/***** Helper *****/
// wrapper function for cuid2
function cuid(opts?: { needGenerate: boolean }) {
if (opts?.needGenerate) {
return char({ length: 24 }).$defaultFn(() => createId());
}
return char({ length: 24 });
}
// wrapper function for timestamp with zone
function tsz() {
return timestamp({ withTimezone: true, mode: 'date', precision: 3 }).notNull();
}
// wrapper function for cratedAt & updatedAt
const timestamps = {
createdAt: tsz().$defaultFn(() => new Date()),
updatedAt: tsz().$onUpdate(() => new Date()),
};
/***** Table *****/
export const adminLevel = pgEnum('admin_level', ['limited', 'normal', 'super']);
export const admin = pgTable('admin', {
id: cuid({ needGenerate: true }).primaryKey(),
email: text().notNull().unique(),
name: text().notNull(),
level: adminLevel().notNull().default('limited'),
passwordHash: text().notNull(),
...timestamps,
});
export const adminSession = pgTable('admin_session', {
encodedToken: text().primaryKey(),
adminId: cuid().notNull().references(() => admin.id),
expiresAt: tsz(),
});
/***** Relation *****/
export const adminRelations = relations(admin, ({ many }) => ({
adminSessions: many(adminSession),
}));
export const adminSessionRelations = relations(adminSession, ({ one }) => ({
admin: one(admin, {
fields: [adminSession.adminId],
references: [admin.id],
}),
}));
いくつかポイント
snake_case
drizzle.config.tsにおいてcasing: 'snake_case'の設定を入れてあることにより、わざわざカラム名を書き直さずとも、snake_caseでdbのカラム名が作られる
// casing: 'snake_case'の設定が
// あるときー
passwordHash: text().notNull(),
// ないときー
passwordHash: text('password_hash').notNull(),
ちなみに、schemaの定義にはdrizzle.config.tsのsnake_caseが適用されるが、クエリ問い合わせの場合にはindex.tsのほう(drizzleインスタンス生成時のオプション)のsnake_caseが参照される
つまり両方にsnake_caseの設定が必要である
なお、テーブル名(pgTableの第一引数)はsnake_caseに変換してくれなかった
enum
enumValuesというフィールドにenumで定義した値が保存されている
たとえば、上記のadmin_levelの選択肢はadminLevel.enumValuesで取り出せる
リレーション
テーブルのリレーションの定義はここに説明があるが…
- テーブル定義時の
column.references()はテーブル自体に外部キー制約を設定する- なくても問題ない(外部キー自体をサポートしていないSQL実装の可能性もあるため)
- cascade deleteなどの設定を追加できる
-
relations(tableName, ({ one, many }) => ({ ... })はdrizzleの型に関連を設定する- なくても問題ない(特に
db.select().from(tableName).leftJoin(...)のように手動で記述する場合) -
admin.adminSessionsのような参照が可能になる - drizzle queryが使えるようになる
- なくても問題ない(特に
ということでreferencesもrelationsも両方書いた方が良い
なおrelation()の戻り値はユーザーが使うことはないんじゃないかと思われるがdrizzle自体が使用するので一意の名前をつけてexportすることは必須
exportしていないとdrizzle query機能が使えない
not nullカラムの追加
新テーブルや空っぽのテーブルなら問題ないのだが、既にレコードを持つテーブルにあとからnotNull()のカラムを追加した場合、(default()が定義されていても)値の不足の旨のエラーが出る
したがって一度nullableで定義し、全行をデフォルト値でupdateした後、再度notNullの定義を追加する必要がある
この作業の本番での実行は難しい気がするが…
適当な値を入れてくれるdrizzle seed機能がある
が、特にpasswordとかはランダム生成されても意味がないので自分でseedすることにした
// hashPasswordの実装はここでは省略
import { hashPassword } from '@shared/logic/password';
import { reset } from 'drizzle-seed';
import { db } from './index';
import * as schema from './schema';
async function insertAdmin(email: string, level: string) {
// super easy password for dev
const password = 'hello123';
const passwordHash = await hashPassword(password);
const name = email.replace(/@.+$/, '');
await db
.insert(schema.admin)
.values({ email, passwordHash, name, level });
}
async function main() {
console.log('seed start');
console.log('reset local db');
await reset(db, schema);
const arr = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
console.log('insert data');
await Promise.all([
insertAdmin('admin@example.com', 'super'),
...arr.map(n => insertAdmin(`member${n}@example.com`, 'normal')),
...arr.map(n => insertAdmin(`supporter${n}@example.com`, 'limited')),
]);
console.log('seed completed');
}
main();
ちなみに、insert(tableName).value({...}, {...}, ...)のようにvalueに複数値を渡すこともできる
順序は保証されない
many-to-manyの使い方を示す
例として、bookとauthorが多対多で関連するとする
(本は複数の著者を持つ可能性があり、著者も複数の著作を持つ可能性がある)
まずは定義
export const author = pgTable('author', {
id: cuid({ needGenerate: true }).primaryKey(),
name: text().notNull(),
});
export const book = pgTable('book', {
id: cuid({ needGenerate: true }).primaryKey(),
title: text().notNull(),
});
export const bookAuthor = pgTable('book_author', {
bookId: cuid().notNull().references(() => book.id),
authorId: cuid().notNull().references(() => author.id),
}, t => ({
pk: primaryKey({ columns: [t.bookId, t.authorId] }),
}));
export const authorRelations = relations(author, ({ many }) => ({
bookAuthors: many(bookAuthor),
}));
export const bookRelations = relations(book, ({ many }) => ({
bookAuthors: many(bookAuthor),
}));
export const bookAuthorRelations = relations(bookAuthor, ({ one }) => ({
author: one(author, {
fields: [bookAuthor.authorId],
references: [author.id],
}),
book: one(book, {
fields: [bookAuthor.bookId],
references: [book.id],
}),
}));
そしてquery
const books = await db.query.book.findMany({
with: { bookAuthors: { with: { author: true } } },
});
結果は以下のようなオブジェクトとなり、books[0].bookAuthors[0].authorのように取得する
const books = [
{
bookAuthors: [{ author: {...} }, { author: {...} }, ...]
},
{
bookAuthors: [{ author: {...} }, { author: {...} }, ...]
},
...,
]
中間テーブルを経由するのが冗長なのでbooks[0].authors[0]みたいにかきたいのだが…
その他
selectやinsertの型は$inferSelect $inferInsertで取得できる
pgTableで生成したテーブル型からその定義を引き出す場合はgetTableConfig関数が便利である
その他にもこのgoodiesのページは便利グッズが多いので見ておくと良い
postgresの定義を見たい場合はpgliteではpsqlに入れないのでこんな感じでsqlを直接実行する
> bun repl
> import { db } from '@shared/db'
> import { sql } from 'drizzle-orm'
> await db.execute(sql`select * from information_schema.tables where table_schema = 'public'`)
> await db.execute(sql`select column_name, data_type from information_schema.columns where table_name = 'user'`)
(pgliteの記事ではないが以下を参照した)
一覧をページングしたい!でも総数も一緒に取得したい!というときにはウィンドウ関数が役に立つ
drizzleではextrasでこれにアクセスできる
具体的にはこうだ
// この辺はパラメータで指定
const per = 10;
const page = 2;
const books = await db.query.book.findMany({
limit: per,
offset: (page - 1) * per,
extras: (book, { sql }) => ({
count: (sql<number>`count(${book.id}) over()`).as('count'),
}),
});
drizzle queryのwhereでメインのテーブルとリレーションテーブルをORで取り出すのは無理ぽいという結論に至った
このドキュメントで書かれている例は「id:1のポストを取り出し、さらに特定の日付のコメントを一緒に持ってくる」であり、「id:1のポスト、かつ・または特定の日付の紐づいたコメント」ではない
await db.query.posts.findMany({
where: (posts, { eq }) => (eq(posts.id, 1)),
with: {
comments: {
where: (comments, { lt }) => lt(comments.createdAt, new Date()),
},
},
});
自分のデモアプリでは「特定の文字列をタイトルに含む本、または特定の文字列を名前に含む著者による本」を抽出したかったのでdrizzle-queryは使用せず、selectとjoinを使った
一つのSQLにまとめられるようにwithも使った
一旦selectしてその結果に応じてもう一度selectするほうがコードとしては単純だが、SQLの問い合わせが増えるということはDBサーバーとアプリケーションとの通信が増えるということであり通信量の面で良くない
一度抽出した後であれば普通の配列としてreduceしたりmapしたりすることが可能である