くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

Drizzle ORMに入門してみた with Turso

ずっと気になってたDrizzle ORMに入門してみたときの備忘録(*´ω`*)
合わせて、Tursoも一緒に使ってみる

version

  • drizzle-orm: v0.38.2
  • drizzle-kit: v0.30.1
  • @libsql/client: v0.14.0
  • nuxt: v3.14.1592

とりあえずお試し

「Get Started」を見ながら、setupと簡単な読み書きをしてみる

Get Started with Drizzle and Turso | Drizzle ORM - SQLite

インストール

$ pnpm add drizzle-orm @libsql/client
$ pnpm add -D drizzle-kit
  • drizzle-orm ... テーブル定義やクエリなどのORM部分
  • drizzle-kit ... マイグレーションなどのツールキット

DB接続情報の設定

アプリが使う接続情報を設定する

# .env
NUXT_DATABASE_URL=
NUXT_DATABASE_AUTH_TOKEN=

nuxt.config.tsも設定

// nuxt.config.ts
export default defineNuxtConfig({
  // ...略
  runtimeConfig: {
    database: {
      url: process.env.NUXT_DATABASE_URL || "",
      authToken: process.env.NUXT_DATABASE_AUTH_TOKEN || "",
    }
  }
});

テーブルを用意する

SQLiteの場合は、こんな感じで作れるっぽい

// server/database/schema.ts
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";

/// ユーザテーブル
export const usersTable = sqliteTable("users_table", {
  id: text().primaryKey(),
  name: text().notNull(),
  email: text().notNull().unique(),
});

Drizzleの設定ファイルを用意する

マイグレーションツールのdrizzle-kit用の設定も用意

// drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  // Drizzleが生成するファイルの出力先
  out: './drizzle',
  // テーブルなどのschemaファイルの配置場所
  schema: './server/database/schema.ts',
  // DBの種類
  dialect: 'turso',
  // DBの接続情報
  dbCredentials: {
    url: process.env.NUXT_DATABASE_URL || "",
    authToken: process.env.NUXT_DATABASE_AUTH_TOKEN,
  },
});

DBをデプロイする

もろもろの設定ができたら、shemaをpushしてみる
scriptsを設定しておくと便利

"scripts": {
  "db:push": "drizzle-kit push"
},

あとは実行すればOK

$ pnpm db:push

まずは読み書き

とりあえず、ユーザの作成とEmailでの検索はこんな感じ
接続情報はuseRuntimeConfig()から取得

// server/repository/usersRepository.ts
import { eq } from "drizzle-orm";
import { User, usersTable } from "../database/schema";
import { drizzle } from "drizzle-orm/libsql";

// usersTableの型定義
export type User = typeof usersTable.$inferInsert;

// drizzleクライアントを作成
export function createDrizzleClient() {
  const config = useRuntimeConfig().database;
  return drizzle({ connection: { url: config.url, authToken: config.authToken, } });
}

// ユーザの作成
interface CreateUserParams { uid: string, name: string, email: string; };
export async function createUser({ uid, email, name }: CreateUserParams): Promise<void> {
  const db = createDrizzleClient();

  const user: User = { id: uid, email, name };

  // insert
  await db.insert(usersTable).values(user);
}

// Emailでユーザの検索
export async function findUserByEmail(email: string): Promise<User | null> {
  const db = createDrizzleClient();

  // select
  const res = await db.select().from(usersTable).where(eq(usersTable.email, email));
  
  return res.length > 0 ? res[0] : null;
}

SELECTの便利機能

Drizzle Queriesという、select時に便利な機能もあるっぽい

drizzleクライアント作成時にschemaを設定すると、
db.query.usersTableが生えてきて、SELECTが楽にできる

// server/repository/usersRepository.ts
import { eq } from "drizzle-orm";
import { User, usersTable } from "../database/schema";
import { drizzle } from "drizzle-orm/libsql";

// usersTableの型定義
export type User = typeof usersTable.$inferInsert;

// drizzleクライアントを作成
export function createDrizzleClient() {
  const config = useRuntimeConfig().database;
  return drizzle({ 
    connection: { url: config.url, authToken: config.authToken, },
    // schemaを設定すると
    schema: { users: usersTable },
  });
}

// Emailでユーザの検索
export async function findUserByEmail(email: string): Promise<User | null> {
  const db = createDrizzleClient();

  // db.query.usersTableが生えてくる
  const res = await db.query.users.findFirst({ where: eq(usersTable.email, email) });
  return res ?? null;
}

update/delete

ちなみに、更新や削除はこんな感じ

interface UpdateUserParams { name: string, email: string; };
export async function updateUser(uid: string, params: UpdateUserParams): Promise<void> {
  const db = createDrizzleClient();
  await db.update(usersTable).set(params).where(eq(usersTable.id, uid));
}

export async function deleteUser(uid: string): Promise<void> {
  const db = createDrizzleClient();
  await db.delete(usersTable).where(eq(usersTable.id, uid));
}

基礎知識を知る

「Get Started」でなんとなく理解したので、
構成要素とかもう少し理解を深めてく

左のメニューにある「Fundamentals」あたりを見ていく

テーブル定義/Schemaまわり

schemaはフォルダでもOK

import { defineConfig } from "drizzle-kit";
export default defineConfig({
  dialect: 'postgresql', // 'mysql' | 'sqlite' | 'turso'
  // ファイルの場合
  schema: './src/db/schema.ts',
  // フォルダの場合
  schema: './src/db/schema', 
})

詳しくは、drizzle.config.tsのマニュアルあたりに

対応しているDBの機能

詳しくは、メニュー左の「MANAGE SCHEMA」あたりに

テーブル定義

ざっくり書くとこんな感じらしい
<DBのカラム名>は任意で、省略すると<tsのカラム名>と同じになる

import { sqliteTable, integer } from "drizzle-orm/sqlite-core"

export const <tsのテーブル名> = sqliteTable('<DBのテーブル名>', {
  <tsのカラム名>: <カラムの型>("<DBのカラム名>")
});

TypeScriptだとキャメルケースが一般的だけど、
DBではスネークケースが一般的なので、こんな感じにできるよう

import { sqliteTable, integer } from "drizzle-orm/sqlite-core"
export const users = pgTable('users', {
  firstName: text('first_name')
})

自動で変換してくれる機能もあるので、そっちを使ってもOK

import { sqliteTable, integer } from "drizzle-orm/sqlite-core"
export const users = pgTable('users', {
  firstName: text()
})

// casingで自動変換
const db = drizzle({ connection: { /* 略 */ }, casing: 'snake_case' });

共通的なカラム

created_atなど共通的なカラムも再利用できるよう

// 共通のカラム
const timestamps = {
  updated_at: timestamp(),
  created_at: timestamp().defaultNow().notNull(),
  deleted_at: timestamp(),
}

// 個別のカラム
export const users = pgTable('users', {
  id: integer(),
  ...timestamps
})

export const posts = pgTable('posts', {
  id: integer(),
  ...timestamps
})

制約(constraints)

import { sql } from "drizzle-orm";
import { integer, sqliteTable, /* 略 */ } from "drizzle-orm/sqlite-core";

const table = sqliteTable('table', {
  // default
  int1: integer('int1').default(42),
  int2: integer('int2').default(sql`(abs(42))`),
  
  // not null
  numInt: integer('numInt').notNull(),
  
  // primary
  id: integer("id").primaryKey(),
});

// unique
const table = sqliteTable('table', {
  // unique
  id: int('id').unique('custom_name'), // インデックス名指定
  id2: int('id2').unique(), // 省略すると`<table名>_<column名>_unique`になる
}, (table) => ({
  // 複合キー
  unq: unique('custom_name').on(t.id, t.name)
  unq2: unique().on(t.id, t.name),
}));

// primary key
const table = sqliteTable('table', {
  // primary
  id: integer("id").primaryKey(),
  
  authorId: integer("author_id"),
  bookId: integer("book_id"),
}, (table) => ({
  // 複合キー
  pk: primaryKey({ columns: [table.bookId, table.authorId] }),
}));


// foreign key
export const user = sqliteTable("user", {
  id: integer("id").primaryKey({ autoIncrement: true }),
});

export const book = sqliteTable("book", {
  // foreign key
  authorId: integer("author_id").references(() => user.id)
});

indexes

export const user = sqliteTable("user", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name"),
  email: text("email"),
}, (table) => {
  return {
    nameIdx: index("name_idx").on(table.name),
    emailIdx: uniqueIndex("email_idx").on(table.email),
  };
});

view

export const user = sqliteTable("user", {
  id: integer().primaryKey({ autoIncrement: true }),
  role: text().$type<"admin" | "customer">(),
});
export const customersView = sqliteView("customers_view").as((qb) => {
  return qb.select({
      id: user.id,
      name: user.name,
      email: user.email,
    })
    .from(user)
    .where(eq(user.role, "customer"));
);

クエリ周り

SQLっぽい書き方と、SQLっぽくない書き方(Query)のなど、いろいろ書き方があるっぽい
どちらもで書いても同じクエリが発行され、Drizzle側でいい感じにしてくれるらしい

// SQLっぽい書き方
await db.select().from(posts)
    .leftJoin(comments, eq(posts.id, comments.post_id))
    .where(eq(posts.id, 10))

// SQLっぽくない書き方(Query)
const result = await db.query.users.findMany({
    with: { posts: true },
});

細かい書き方は左のメニューの「ACCESSS YOUR DATA」にある

集合操作(UNIONなど)

UNION/INTERSECT/EXCEPTなどは、このあたり

トランザクション

トランザクションはこのあたり

await db.transaction(async (tx) => {
  const [account] = await tx.select({ balance: accounts.balance }).from(accounts).where(eq(users.name, 'Dan'));
  if (account.balance < 100) {
    // This throws an exception that rollbacks the transaction.
    tx.rollback()
  }
  await tx.update(accounts).set({ balance: sql`${accounts.balance} - 100.00` }).where(eq(users.name, 'Dan'));
  await tx.update(accounts).set({ balance: sql`${accounts.balance} + 100.00` }).where(eq(users.name, 'Andrew'));
});

DBのマイグレーション

マイグレーションはこのあたり

データベースを真とする(Database first)か、
ソースコードを真とする(Codebase first)か、
の2つの考え方があり、6つのアプローチがある

関連コマンド

drizzle-kit migrate ... マイグレーションの実行
drizzle-kit generate ... マイグレーションファイルの生成
drizzle-kit push ... データベースへのpush
drizzle-kit pull ... データベースからのpull

各コマンドの詳細は、左メニューの「MIGRATIONS」にある

6つのアプローチ

1. データベースを他のツールや自身のSQLで管理している場合
ツールなどで実行後、最新のDBからdrizzle-kit pullして、
DBからtsコードを生成(Database first)

2. コードで管理したいが、マイグレーションファイルは使いたくない場合
tsコードを変更後、drizzle-kit pushして、DBに反映(Codbase first)

3. コマンドでマイグレーションファイルで反映したい場合
tsコード変更後、drizzle-kit generatesqlファイルを生成し、
drizzle-kit migrateで、DBに反映(Codebase first)

4. コード実行時にマイグレーションファイルで反映したい場合
tsコード変更後、drizzle-kit generatesqlファイルを生成し、
tsコード上でawait migrate(db);を実行し、DBに反映(Codebase first)

5. 外部ツールや自分でマイグレーションファイルを反映したい場合
tsコード変更後、drizzle-kit generatesqlファイルを生成し、
手動または別のツールで、sqlファイルを実行し、DBに反映(Codebase first)

6. Atlas経由でマイグレーションファイルを反映したい場合
tsコード変更後、drizzle-kit exportsqlを生成(コンソールに出力)し、
Atlas経由でsqlを実行し、DBに反映(Codebase first)

カスタムマイグレーション

Drizzle Kitの仕組みには乗せたい(drizzle-kit migrateで反映したい)が、
SQLファイルは自分で書きたい場合、空のSQLファイルを作成することもできるらしい

$ drizzle-kit generate --custom --name=seed-users
# => ./drizzle/0001_seed-users.sqlを生成

その他もろもろ

リードレプリカ

Read Replicaにも対応。selectはレプリカ、書き込みはprimaryを自動で選択してくれるっぽい

ロギング

ログ出力とかもあり、カスタマイズもできる

// ロギングの有効化
const db = drizzle({ logger: true });

// custome writer
class MyLogWriter implements LogWriter {
  write(message: string) {
    // Write to file, stdout, etc.
  }
}
const logger = new DefaultLogger({ writer: new MyLogWriter() });
const db = drizzle({ logger });

// custome logger
class MyLogger implements Logger {
  logQuery(query: string, params: unknown[]): void {
    console.log({ query, params });
  }
}
const db = drizzle({ logger: new MyLogger() });

valibot/zodなど用のプラグイン

いくつかプラグインが用意されていて、
テーブル定義からvalibotのshemaを生成してくれるっぽい

import { pgTable, text, integer } from 'drizzle-orm/pg-core';
import { createInsertSchema } from 'drizzle-valibot';
import { parse } from 'valibot';

// table
const users = pgTable('users', {
  id: integer().generatedAlwaysAsIdentity().primaryKey(),
  name: text().notNull(),
  age: integer().notNull()
});

// valibot schema
const userInsertSchema = createInsertSchema(users);

// parse error
const user = { name: 'John' };
const parsed: { name: string, age: number } = parse(userInsertSchema, user); // Error: `age` is not defined

// parse success
const user = { name: 'Jane', age: 30 };
const parsed: { name: string, age: number } = parse(userInsertSchema, user); // Will parse successfully

// execute query
await db.insert(users).values(parsed);

SQLの発行

sqlオペレータでいろいろできるっぽい

デフォルト値の生成関数

デフォルト値の値は、関数で設定できたりするらしい
なので、UUID/UILD/CUID/shortidなどなど、いろいろできそう

import { text, sqliteTable } from "drizzle-orm/sqlite-core";
import { createId } from '@paralleldrive/cuid2';

const table = sqliteTable('table', {
    id: text().$defaultFn(() => createId()),
});

onDeleteやonUpdate時の動作

外部キー制約の部分で、指定できるっぽい

import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
    id: serial('id').primaryKey(),
});

export const posts = pgTable('posts', {
    id: serial('id').primaryKey(),
    author: integer('author')
    // ここで指定
    .references(() => users.id, {onDelete: 'cascade'}).notNull(),
});

以上!! なんとなくわかった気がする...(*´ω`*)

参考にしたサイト様