TypeScriptによるDB操作の決定版 Drizzle × Kysely
はじめに
TypesScriptでDBを扱う際のライブラリ選定の基準には、次のようなものが挙げられます。
- クエリ
- 書きやすさ
- 型安全性
- エコシステム
- DSLの有無
- Migrationのしやすさ
これらの観点から、クエリの書きやすさと型安全性に優れたKyselyと、エコシステムが充実しているDrizzleを組み合わせることで、快適な開発体験を得ることができました。
弊社ではPrisma, TypeORMなどのORMも使っていた経緯がありますが、TypeORMは型安全性が低く、また、PrismaはSQLの書き方に慣れているエンジニアにとっては書きづらく、DSLのキャッチアップなども必要になるという問題がありました。
本記事では、公式ドキュメントのサンプルコードを元に、KyselyとDrizzleの特徴を比較し、それぞれの特徴を活かした開発方法を紹介します。
それぞれの特徴
Kysely
Kyselyは、非常にSQLに近い感覚でクエリを書くことができるクエリビルダーで、SQLに慣れ親しんでいるエンジニアにとっては、非常に書きやすいと感じると思います。
import { db } from './database'
import { PersonUpdate, Person, NewPerson } from './types'
export async function findPersonById(id: number) {
return await db.selectFrom('person')
.where('id', '=', id)
.selectAll()
.executeTakeFirst()
}
export async function findPeople(criteria: Partial<Person>) {
let query = db.selectFrom('person')
if (criteria.id) {
query = query.where('id', '=', criteria.id) // Kysely is immutable, you must re-assign!
}
if (criteria.first_name) {
query = query.where('first_name', '=', criteria.first_name)
}
if (criteria.last_name !== undefined) {
query = query.where(
'last_name',
criteria.last_name === null ? 'is' : '=',
criteria.last_name
)
}
if (criteria.gender) {
query = query.where('gender', '=', criteria.gender)
}
if (criteria.created_at) {
query = query.where('created_at', '=', criteria.created_at)
}
return await query.selectAll().execute()
}
export async function updatePerson(id: number, updateWith: PersonUpdate) {
await db.updateTable('person').set(updateWith).where('id', '=', id).execute()
}
export async function createPerson(person: NewPerson) {
return await db.insertInto('person')
.values(person)
.returningAll()
.executeTakeFirstOrThrow()
}
export async function deletePerson(id: number) {
return await db.deleteFrom('person').where('id', '=', id)
.returningAll()
.executeTakeFirst()
}
また、かなり多くのSQL構文をサポートしており、aliasなども型安全に記述できるため、複雑なクエリも書きやすく、直接はサポートしていない構文も型情報を付与しながら書くことができます。
例えば、json_agg
やto_json
などの関数も型安全に書くことができます。
import { jsonArrayFrom } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
jsonArrayFrom(
eb.selectFrom('pet')
.select(['pet.id as pet_id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
.orderBy('pet.name')
).as('pets')
])
.execute()
集計関数や文字列処理などの直接はサポートしていない関数も、型情報を付与しながら書くことができます。
import { sql } from 'kysely'
const result = await db.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select(({ fn, val, ref }) => [
'person.id',
// The `fn` module contains the most common
// functions.
fn.count<number>('pet.id').as('pet_count'),
// You can call any function by calling `fn`
// directly. The arguments are treated as column
// references by default. If you want to pass in
// values, use the `val` function.
fn<string>('concat', [
val('Ms. '),
'first_name',
val(' '),
'last_name'
]).as('full_name_with_title'),
// You can call any aggregate function using the
// `fn.agg` function.
fn.agg<string[]>('array_agg', ['pet.name']).as('pet_names'),
// And once again, you can use the `sql`
// template tag. The template tag substitutions
// are treated as values by default. If you want
// to reference columns, you can use the `ref`
// function.
sql<string>`concat(
${ref('first_name')},
' ',
${ref('last_name')}
)`.as('full_name')
])
.groupBy('person.id')
.having((eb) => eb.fn.count('pet.id'), '>', 10)
.execute()
ただ、Migrationに関しては、全てクエリで記述する必要があり、テーブルスキーマからMigration用のSQLを自動生成してくれるような他のライブラリと比べると、手間感は否めません。
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('person')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('first_name', 'varchar', (col) => col.notNull())
.addColumn('last_name', 'varchar')
.addColumn('gender', 'varchar(50)', (col) => col.notNull())
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull(),
)
.execute()
await db.schema
.createTable('pet')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('name', 'varchar', (col) => col.notNull().unique())
.addColumn('owner_id', 'integer', (col) =>
col.references('person.id').onDelete('cascade').notNull(),
)
.addColumn('species', 'varchar', (col) => col.notNull())
.execute()
await db.schema
.createIndex('pet_owner_id_index')
.on('pet')
.column('owner_id')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('pet').execute()
await db.schema.dropTable('person').execute()
}
Drizzle
Drizzleのクエリは、Prismaライクな書き方とSQLライクな書き方の両方をサポートしています。
import * as schema from './schema';
import { drizzle } from 'drizzle-orm/...';
const db = drizzle({ schema });
const result = await db.query.users.findMany({
with: {
posts: true,
},
});
const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));
今回は、SQLライクな書き方をしたいのですが、その場合、現状Drizzleはjson_agg
やto_json
をサポートしていないため、型安全にこれらの関数を書こうと思うと、かなりの工夫[1]が必要です。
export function jsonAgg<T extends Record<string, AnyColumn>>(select: T) {
const chunks: SQL[] = [];
Object.entries(select).forEach(([key, column], index) => {
if (index > 0) chunks.push(sql`,`);
chunks.push(sql.raw(`'${key}',`), sql`${column}`);
});
return sql<InferColumnsDataTypes<T>[]>`
coalesce(
json_agg(json_build_object(${sql.fromList(chunks)})),
'[]'
)
`;
}
一方、Migrationに関しては、DrizzleはMigrationの自動生成機能[2]を持っており、TypeScriptで書かれたテーブルスキーマからMigration用のSQLを自動生成してくれるため、Prismaなどと異なりDSLのキャッチアップも不要です。
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
export const pets = pgTable('pets', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
ownerId: integer('owner_id').notNull().references(() => users.id),
});
両者の組み合わせ
Drizzleは、Kyselyと併用するライブラリ「drizzle-kysely」を提供しており、それを使うことでDrizzleで定義したテーブルスキーマの型をKyselyの型に変換して利用することができ、両者のメリットを享受することができます。
import Database from "better-sqlite3";
import { Kysely, SqliteDialect, sql } from "kysely";
import { Kyselify } from "drizzle-orm/kysely";
import { customers, details, employees, orders, products, suppliers } from "./schema";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { drizzle } from "drizzle-orm/better-sqlite3/driver";
const sqlite = new Database("datapack/_sqlite.db");
const drzldb = drizzle(sqlite);
migrate(drzldb, { migrationsFolder: "drizzle" });
interface Database {
customer: Kyselify<typeof customers>;
employee: Kyselify<typeof employees>;
order: Kyselify<typeof orders>;
supplier: Kyselify<typeof suppliers>;
product: Kyselify<typeof products>;
order_detail: Kyselify<typeof details>;
}
const db = new Kysely<Database>({
dialect: new SqliteDialect({
database: sqlite,
}),
});
const main = async () => {
// fully typed Kysely result and query builder
const result = await db.selectFrom("customer").selectAll().execute();
// you can also query with Drizzle ORM whenever needed!
const result2 = drzldb.select().from(customers).all()
}
main()
ただ、型変換の際のCase変換などの機能は持っていないため、DBのテーブル名やカラム名がスネークケースで、コードはキャメルケースで書きたい場合、少し工夫が必要です。
import { type ToSnake, toSnake } from 'ts-case-convert';
const objectToSnakeWithOneDepth = <
T extends {
[key: string]: unknown;
},
>(
obj: T,
): {
[K in keyof T as ToSnake<K>]: T[K];
} => {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [toSnake(key), value]),
) as {
[K in keyof T as ToSnake<K>]: T[K];
};
};
await dbClient
.insertInto('user')
.values(
objectToSnakeWithOneDepth({
id,
firstName,
lastName,
}),
)
.execute();
まとめ
KyselyとDrizzleのそれぞれの特徴を活かすことで、快適にTypesScriptでDBを扱うことができるようになりました。
Drizzleの今後のSQL関数のサポートにも注目していきたいと思います。
Discussion