DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでKyselyタイプセーフクエリビルダーを設計する:複雑なJOIN・動的フィルタ

はじめに

KyselyはTypeScriptファーストのSQLクエリビルダーだ。生SQLの柔軟性を保ちつつ、型システムで誤ったカラム名や結合条件をコンパイル時に検出できる。Claude Codeを使い、型エイリアス定義から動的フィルタ・ウィンドウ関数・CTEまで体系的に設計する方法を解説する。

Insertable/Selectable/Updateable型エイリアス

Kyselyはテーブル定義からInsertable<T>Selectable<T>Updateable<T>の3つの型を自動導出できる。これをエイリアスとして明示することで、INSERT/SELECT/UPDATE操作の型安全性を確保する。

import { Kysely, PostgresDialect } from 'kysely';
import type { Insertable, Selectable, Updateable } from 'kysely';

// テーブルスキーマ定義
interface UserTable {
  id: number;            // 自動生成
  email: string;
  name: string;
  created_at: Date;      // 自動生成
  updated_at: Date;      // 自動生成
}

interface Database {
  users: UserTable;
  posts: PostTable;
  comments: CommentTable;
}

// 操作別型エイリアス(明示的に定義して再利用)
type UserRow = Selectable<UserTable>;         // SELECT結果:全カラム
type NewUser = Insertable<UserTable>;         // INSERT用:id/created_at/updated_atを省略可
type UserUpdate = Updateable<UserTable>;      // UPDATE用:全カラムOptional

// 使用例
async function createUser(data: NewUser): Promise<UserRow> {
  return db
    .insertInto('users')
    .values(data)
    .returningAll()
    .executeTakeFirstOrThrow();
}
Enter fullscreen mode Exit fullscreen mode

Claude Codeへの指示例:「UserTableのInsertable/Selectable/Updateable型エイリアスを定義して、各CRUD関数で型を使い分けて」

query.where()チェーンで動的フィルタ

検索条件が動的に変わるAPIでは、条件に応じてwhere()を後付けチェーンするパターンが有効だ。Kyselyはクエリビルダーをイミュータブルに扱えるため、条件分岐でも型安全性を維持できる。

interface UserFilters {
  email?: string;
  nameContains?: string;
  createdAfter?: Date;
  createdBefore?: Date;
  limit?: number;
  offset?: number;
}

async function findUsers(filters: UserFilters): Promise<UserRow[]> {
  let query = db
    .selectFrom('users')
    .selectAll();

  // 動的フィルタ:条件があるときだけwhereを追加
  if (filters.email !== undefined) {
    query = query.where('email', '=', filters.email);
  }

  if (filters.nameContains !== undefined) {
    query = query.where('name', 'ilike', `%${filters.nameContains}%`);
  }

  if (filters.createdAfter !== undefined) {
    query = query.where('created_at', '>=', filters.createdAfter);
  }

  if (filters.createdBefore !== undefined) {
    query = query.where('created_at', '<=', filters.createdBefore);
  }

  // ページネーション
  if (filters.limit !== undefined) {
    query = query.limit(filters.limit);
  }

  if (filters.offset !== undefined) {
    query = query.offset(filters.offset);
  }

  return query.execute();
}
Enter fullscreen mode Exit fullscreen mode

Claude Codeへの指示例:「UserFiltersインターフェースを受け取り、存在するフィールドだけwhereチェーンを追加する動的フィルタ関数を作って」

ウィンドウ関数 rank() over partition

Kyselyはウィンドウ関数もタイプセーフに使える。カテゴリ別ランキングや月別集計など、複雑なSQLが必要な場面で威力を発揮する。

import { sql } from 'kysely';

// カテゴリ別の売上ランキングを取得
async function getProductRankByCategory() {
  return db
    .selectFrom('products')
    .select([
      'id',
      'name',
      'category_id',
      'sales_amount',
      // rank() over (partition by category_id order by sales_amount desc)
      sql<number>`rank() over (
        partition by category_id
        order by sales_amount desc
      )`.as('rank_in_category'),
      // row_numberも同様
      sql<number>`row_number() over (
        partition by category_id
        order by sales_amount desc, id asc
      )`.as('row_num'),
    ])
    .orderBy('category_id')
    .orderBy('rank_in_category')
    .execute();
}

// 結果の型は自動推論
// { id: number; name: string; category_id: number; sales_amount: number;
//   rank_in_category: number; row_num: number }[]
Enter fullscreen mode Exit fullscreen mode

.with()でCTEを使った複雑なレポートクエリ

CTE(Common Table Expression)は複雑な集計クエリを可読性高く記述するための機能だ。Kyselyの.with()を使うことで、型安全なCTEを組み立てられる。

// 月次売上レポート:CTE + JOINの組み合わせ
async function getMonthlySalesReport(year: number) {
  return db
    // CTE 1: 月別売上集計
    .with('monthly_sales', (qb) =>
      qb
        .selectFrom('orders')
        .select([
          sql<number>`EXTRACT(MONTH FROM created_at)`.as('month'),
          sql<number>`SUM(total_amount)`.as('total_sales'),
          sql<number>`COUNT(*)`.as('order_count'),
        ])
        .where(sql`EXTRACT(YEAR FROM created_at)`, '=', year)
        .groupBy(sql`EXTRACT(MONTH FROM created_at)`)
    )
    // CTE 2: 月別新規ユーザー数
    .with('monthly_new_users', (qb) =>
      qb
        .selectFrom('users')
        .select([
          sql<number>`EXTRACT(MONTH FROM created_at)`.as('month'),
          sql<number>`COUNT(*)`.as('new_users'),
        ])
        .where(sql`EXTRACT(YEAR FROM created_at)`, '=', year)
        .groupBy(sql`EXTRACT(MONTH FROM created_at)`)
    )
    // メインクエリ:CTEをJOIN
    .selectFrom('monthly_sales as ms')
    .leftJoin('monthly_new_users as mu', 'ms.month', 'mu.month')
    .select([
      'ms.month',
      'ms.total_sales',
      'ms.order_count',
      'mu.new_users',
    ])
    .orderBy('ms.month')
    .execute();
}
Enter fullscreen mode Exit fullscreen mode

Claude Codeへの指示例:「月次売上と新規ユーザー数を結合するKysely CTEレポートクエリを作って。with()を2つ使ってJOINして」

まとめ

  • 型エイリアスInsertable<T>Selectable<T>Updateable<T>を明示的にエイリアス化し、操作ごとの型安全性を確保する
  • 動的フィルタquery.where()のイミュータブルチェーンで、条件に応じてSQLを組み立て、型推論を維持する
  • ウィンドウ関数sql<number>rank() over partition`として型付きで埋め込み、カテゴリ別ランキングを実現する
  • CTE.with()で複数のCTEを定義してJOINし、複雑なレポートクエリを可読性高く構成する

Claude Codeにテーブル定義とクエリ要件を伝えれば、型エイリアス・動的フィルタ・CTE設計まで一気に生成できる。


みょうがのCode Review Pack(¥980)では、Kysely・TypeScript設計レビュー用のClaude Codeカスタムスキルを提供しています。
Code Review Pack を見る →

Top comments (0)