Open9

drizzle ormのメモ

kawarimidollkawarimidoll

Drizzleを設定した

https://orm.drizzle.team/docs/overview

dev環境ではPGliteを使用
本番環境ではほかのpostgresqlサービスを使うかもしれない

https://pglite.dev/docs/

環境で変わりそうなところを.envに設定

.env
DATABASE_URL="./data"
DRIVER="pglite"

設定ファイルは以下

https://orm.drizzle.team/docs/drizzle-config-file

drizzle.config.ts
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);
kawarimidollkawarimidoll

dbのエントリーポイント

index.ts
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 });

kawarimidollkawarimidoll

idにはcuid2を採用 固定長なので型はchar()でよかろう

https://github.com/paralleldrive/cuid2

schema.ts
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.tssnake_caseが適用されるが、クエリ問い合わせの場合にはindex.tsのほう(drizzleインスタンス生成時のオプション)のsnake_caseが参照される
つまり両方にsnake_caseの設定が必要である

なお、テーブル名(pgTableの第一引数)はsnake_caseに変換してくれなかった

enum

enumValuesというフィールドにenumで定義した値が保存されている
たとえば、上記のadmin_levelの選択肢はadminLevel.enumValuesで取り出せる

リレーション

テーブルのリレーションの定義はここに説明があるが…

https://orm.drizzle.team/docs/relations#foreign-keys

  • テーブル定義時の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機能が使えない

https://orm.drizzle.team/docs/rqb

not nullカラムの追加

新テーブルや空っぽのテーブルなら問題ないのだが、既にレコードを持つテーブルにあとからnotNull()のカラムを追加した場合、(default()が定義されていても)値の不足の旨のエラーが出る
したがって一度nullableで定義し、全行をデフォルト値でupdateした後、再度notNullの定義を追加する必要がある
この作業の本番での実行は難しい気がするが…

kawarimidollkawarimidoll

適当な値を入れてくれるdrizzle seed機能がある

https://orm.drizzle.team/docs/seed-overview

が、特にpasswordとかはランダム生成されても意味がないので自分でseedすることにした

seed.ts
// 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に複数値を渡すこともできる
順序は保証されない

https://bsky.app/profile/kawarimidoll.bsky.social/post/3ld5wes4g5c2o

kawarimidollkawarimidoll

many-to-manyの使い方を示す

例として、bookとauthorが多対多で関連するとする
(本は複数の著者を持つ可能性があり、著者も複数の著作を持つ可能性がある)

https://bsky.app/profile/kawarimidoll.bsky.social/post/3ld62dca4nk2t

まずは定義

schema.ts
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

getBooks.ts
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]みたいにかきたいのだが…

kawarimidollkawarimidoll

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の記事ではないが以下を参照した)
https://sumito.jp/2023/07/10/postgresql-table-structure-check-methods/
https://zenn.dev/joo_hashi/articles/df266aa626302d

kawarimidollkawarimidoll

一覧をページングしたい!でも総数も一緒に取得したい!というときにはウィンドウ関数が役に立つ

https://qiita.com/HiromuMasuda0228/items/0b20d461f1a80bd30cfc

https://www.niandc.co.jp/tech/20220613_2187/

drizzleではextrasでこれにアクセスできる

https://orm.drizzle.team/docs/rqb#include-custom-fields

https://github.com/drizzle-team/drizzle-orm/discussions/1507#discussioncomment-7561920

具体的にはこうだ

// この辺はパラメータで指定
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'),
  }),
});
kawarimidollkawarimidoll

drizzle queryのwhereでメインのテーブルとリレーションテーブルをORで取り出すのは無理ぽいという結論に至った

https://orm.drizzle.team/docs/rqb#select-filters

このドキュメントで書かれている例は「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したりすることが可能である

https://orm.drizzle.team/docs/joins#aggregating-results