🎨

TypeScriptによるDB操作の決定版 Drizzle × Kysely

2024/10/05に公開

はじめに

TypesScriptでDBを扱う際のライブラリ選定の基準には、次のようなものが挙げられます。

  • クエリ
    • 書きやすさ
    • 型安全性
  • エコシステム
    • DSLの有無
    • Migrationのしやすさ

これらの観点から、クエリの書きやすさと型安全性に優れたKyselyと、エコシステムが充実しているDrizzleを組み合わせることで、快適な開発体験を得ることができました。

弊社ではPrisma, TypeORMなどのORMも使っていた経緯がありますが、TypeORMは型安全性が低く、また、PrismaはSQLの書き方に慣れているエンジニアにとっては書きづらく、DSLのキャッチアップなども必要になるという問題がありました。

本記事では、公式ドキュメントのサンプルコードを元に、KyselyとDrizzleの特徴を比較し、それぞれの特徴を活かした開発方法を紹介します。

それぞれの特徴

Kysely

Kyselyは、非常にSQLに近い感覚でクエリを書くことができるクエリビルダーで、SQLに慣れ親しんでいるエンジニアにとっては、非常に書きやすいと感じると思います。

query-sample.ts
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_aggto_jsonなどの関数も型安全に書くことができます。

json-agg.ts
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()

集計関数や文字列処理などの直接はサポートしていない関数も、型情報を付与しながら書くことができます。

function-calls.ts
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を自動生成してくれるような他のライブラリと比べると、手間感は否めません。

migration.ts
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ライクな書き方の両方をサポートしています。

prisma-like.ts
import * as schema from './schema';
import { drizzle } from 'drizzle-orm/...';
const db = drizzle({ schema });
const result = await db.query.users.findMany({
  with: {
    posts: true,
  },
});
sql-like.ts
const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));

今回は、SQLライクな書き方をしたいのですが、その場合、現状Drizzleはjson_aggto_jsonをサポートしていないため、型安全にこれらの関数を書こうと思うと、かなりの工夫[1]が必要です。

json-agg.ts
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のキャッチアップも不要です。

table-schema.ts
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の型に変換して利用することができ、両者のメリットを享受することができます。

drizzle-kysely.ts
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のテーブル名やカラム名がスネークケースで、コードはキャメルケースで書きたい場合、少し工夫が必要です。

case-conversion.ts
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関数のサポートにも注目していきたいと思います。

脚注
  1. DrizzleでSQLライクにjson_addを書く方法 ↩︎

  2. DrizzleのMigrationについて ↩︎

mutex Official Tech Blog

Discussion