【Drizzle】queryに__typeを埋め込んで型強力にする

こんにちは、フリーランスエンジニアの太田雅昭です

以前、DrizzleのViewで__typeを埋め込んで、型を強化する話を書きました。

今回は、Viewを使わずqueryでやってしまおうというお話です。

実行環境

  • PostgreSQL
  • drizzle-orm: 0.44.4

tableを用意する

下記のテーブルを用います。

export const usersTable = pgTable('users', {
  id: text().notNull(),
  handle: text().unique(),
  email: text(),
}

ユーティリティ関数を用意する

下記のようなユーティリティ関数を作ります。

export function mkType<T extends string>(type: T) {
  return { __type: sql<T>`${sql.raw(`'${type}'`)}`.as('__type') }
}

これは、フィールドに__typeとして固定値を入れるためのものです。詳細は前回の記事を参照ください。

queryを作る

queryを作ります。

import { SQL } from "drizzle-orm";

// queryで受け取る引数
export type CustomQueryParams = {
  where?: SQL<unknown>
  orderBy?: SQL<unknown>
  limit?: number
  offset?: number
}

// 自身参照用
export const userAsSelfQuery = (params?: CustomQueryParams) => db()
  .query
  .usersTable
  .findOne({
    extras: { ...mkType('user_self') },
    with: {
      posts: true,
    },
    ...params,
  });

// 公開用
export const usersAsPublicQuery = (params?: CustomQueryParams) => db()
  .query
  .usersTable
  .findMany({
    extras: { ...mkType('users_public') },
    columns: { email: false, }, // 機密情報を除く
    with: {
      posts: true,
    },
    ...params,
  });

型を作ります。type指定しているため、クライアントから読み込んでも安全です。

// type指定でimportすることで、サーバークライアント間のエラーを防ぐ
import type {userAsSelfQuery, usersAsPublicQuery} from 'queries';

export type UserAsSelf = Awaited<ReturnType<typeof userAsSelfQuery>>
export type UserAsPublic = Awaited<ReturnType<typeof usersAsPublicQuery>>[number]

使ってみる

下記のようにして使います。

function showMe(me: UserAsSelf) {
  console.log(me);
}

function showAsPublic(user: UserAsPublic){
  console.log(user);
}

async function test() {
  const me = await userAsSelfQuery();
  const usersPublic = await usersAsPublic();

  showMe(me);
  showMe(usersPublic[0]); // 型エラーになる

  showPublic(me); // 型エラーになる
  showPublic(usersPublic[0]);
}

__typeに固定値が入っているため、予期しないところでの使用時に型エラーが出るようになります。

型生成ユーティリティを作る

これまではReturnTypeを使ってきましたが、使い勝手が悪いためユーティリティを作りました。説明が大変ですので、サンプルコードを載せます。

import type { BuildQueryResult, DBQueryConfig, ExtractTablesWithRelations, SQL } from 'drizzle-orm';
import { and, eq, isNotNull, relations, sql } from 'drizzle-orm';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { drizzle } from 'drizzle-orm/postgres-js';
import { Pool } from 'pg';


//=== スキーマ ===//

const usersTable = pgTable('users', {
  id: text().primaryKey(),
  name: text(),
  email: text(),
  passwordHash: text(),
  deletedAt: timestamp(),
});

const postsTable = pgTable('posts', {
  id: text().primaryKey(),
  userId: text().references(() => usersTable.id),
  title: text(),
  content: text(),
});

const userRelations = relations(usersTable, ({ many }) => ({
  posts: many(postsTable),
}));

const schema = { usersTable, postsTable, userRelations }


//=== db ===//

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const db = drizzle({
  client: pool,
  schema,
});


//=== 型とユーティリティ ===//

type TSchema = ExtractTablesWithRelations<typeof schema>

type BaseQueryConfig<TableName extends keyof TSchema> = DBQueryConfig<
  'one' | 'many',
  boolean,
  TSchema,
  TSchema[TableName]
>

export type SelectionConfig<TableName extends keyof TSchema> =
  Omit<BaseQueryConfig<TableName>, 'extras'> & { extras?: (fields: unknown) => unknown, where?: SQL<unknown> }

export type InferSelection<
  TableName extends keyof TSchema,
  QBConfig extends SelectionConfig<TableName> = {}
> = BuildQueryResult<TSchema, TSchema[TableName], QBConfig>

// extra type生成
export function mkType<T extends string>(type: T) {
  return { __type: sql<T>`${sql.raw(`'${type}'`)}`.as('__type') }
}


//=== selection ===//

const postSelection = {
  extras: () => ({ ...mkType('publicPost') }), // 関数にすることで入れ子時に型エラーが出なくなる
  columns: {
    id: true,
    title: true,
  },
} satisfies SelectionConfig<'postsTable'>

const userSelection = {
  extras: () => ({ ...mkType('publicUser') }),
  columns: {
    passwordHash: false,
  },
  with: {
    posts: postSelection,
  },
  where: isNotNull(usersTable.deletedAt),
} satisfies SelectionConfig<'usersTable'>


//=== 型生成 ===//

export type PublicPost = InferSelection<'postsTable', typeof postSelection>
// type PublicPost = {
//   id: string;
//   title: string | null;
//   __type: "publicPost";
// }

export type PublicUser = InferSelection<'usersTable', typeof userSelection>
// type PublicUser = {
//   id: string;
//   name: string | null;
//   email: string | null;
//   __type: "publicUser";
//   posts: {
//       id: string;
//       title: string | null;
//       __type: "publicPost";
//   }[];
// }


//=== クエリ ===//

db.query.usersTable.findMany({
  ...userSelection,
  where: and(
    userSelection.where,
    eq(usersTable.id, '1')),
  limit: 10,
}).then(res => console.log(res));
// (parameter) res: {
//   id: string;
//   name: string | null;
//   email: string | null;
//   deletedAt: Date | null;
//   __type: "publicUser";
//   posts: {
//       id: string;
//       title: string | null;
//       __type: "publicPost";
//   }[];
// }[]