Kyselyで型安全なSQLクエリビルダーを実装する実践ガイド


Kyselyで型安全なSQLクエリビルダーを実装する実践ガイド

Kyselyの実践的な活用

KyselyはTypeScript型安全なSQLクエリビルダーですが、実際のアプリケーション開発では、基本的なCRUD以上の高度なパターンが必要です。本記事では、実践的なパターンとベストプラクティスに焦点を当てて解説します。

動的なクエリ構築

フィルタリングの実装

import { Kysely, SelectQueryBuilder } from 'kysely';

interface UserFilters {
  name?: string;
  email?: string;
  ageMin?: number;
  ageMax?: number;
  isActive?: boolean;
}

function buildUserQuery(
  db: Kysely<Database>,
  filters: UserFilters
) {
  let query = db.selectFrom('users').selectAll();

  // 名前で検索(部分一致)
  if (filters.name) {
    query = query.where('name', 'like', `%${filters.name}%`);
  }

  // メールで検索(完全一致)
  if (filters.email) {
    query = query.where('email', '=', filters.email);
  }

  // 年齢範囲
  if (filters.ageMin !== undefined) {
    query = query.where('age', '>=', filters.ageMin);
  }
  if (filters.ageMax !== undefined) {
    query = query.where('age', '<=', filters.ageMax);
  }

  // アクティブステータス
  if (filters.isActive !== undefined) {
    query = query.where('is_active', '=', filters.isActive);
  }

  return query;
}

// 使用例
async function searchUsers(filters: UserFilters) {
  const query = buildUserQuery(db, filters);
  return query.execute();
}

// 動的にフィルタを適用
const users = await searchUsers({
  name: 'Alice',
  ageMin: 20,
  isActive: true
});

ソート順の動的指定

type SortColumn = 'name' | 'email' | 'created_at';
type SortDirection = 'asc' | 'desc';

interface PaginationOptions {
  page: number;
  pageSize: number;
  sortBy?: SortColumn;
  sortDirection?: SortDirection;
}

async function getPaginatedUsers(
  filters: UserFilters,
  options: PaginationOptions
) {
  let query = buildUserQuery(db, filters);

  // ソート
  if (options.sortBy) {
    query = query.orderBy(
      options.sortBy,
      options.sortDirection || 'asc'
    );
  } else {
    query = query.orderBy('created_at', 'desc');
  }

  // ページネーション
  const offset = (options.page - 1) * options.pageSize;
  query = query.limit(options.pageSize).offset(offset);

  // 件数取得も並列実行
  const [users, countResult] = await Promise.all([
    query.execute(),
    buildUserQuery(db, filters)
      .select((eb) => eb.fn.count('id').as('total'))
      .executeTakeFirst()
  ]);

  return {
    users,
    total: Number(countResult?.total || 0),
    page: options.page,
    pageSize: options.pageSize,
    totalPages: Math.ceil(Number(countResult?.total || 0) / options.pageSize)
  };
}

OR条件の構築

interface SearchOptions {
  query: string;
  searchFields: Array<'name' | 'email' | 'phone'>;
}

async function searchUsersAcrossFields(options: SearchOptions) {
  return db
    .selectFrom('users')
    .selectAll()
    .where((eb) => {
      const conditions = options.searchFields.map(field =>
        eb(field, 'like', `%${options.query}%`)
      );
      return eb.or(conditions);
    })
    .execute();
}

// 使用例
const results = await searchUsersAcrossFields({
  query: 'alice',
  searchFields: ['name', 'email']
});
// SELECT * FROM users WHERE name LIKE '%alice%' OR email LIKE '%alice%'

Repository パターン

基本的なRepository

// repositories/user.repository.ts
import { Kysely } from 'kysely';
import { Database } from '../types/database';

export class UserRepository {
  constructor(private db: Kysely<Database>) {}

  async findById(id: number) {
    return this.db
      .selectFrom('users')
      .selectAll()
      .where('id', '=', id)
      .executeTakeFirst();
  }

  async findByEmail(email: string) {
    return this.db
      .selectFrom('users')
      .selectAll()
      .where('email', '=', email)
      .executeTakeFirst();
  }

  async create(data: {
    name: string;
    email: string;
    password_hash: string;
  }) {
    return this.db
      .insertInto('users')
      .values({
        ...data,
        created_at: new Date(),
        updated_at: new Date()
      })
      .returningAll()
      .executeTakeFirst();
  }

  async update(id: number, data: Partial<{
    name: string;
    email: string;
    password_hash: string;
  }>) {
    return this.db
      .updateTable('users')
      .set({
        ...data,
        updated_at: new Date()
      })
      .where('id', '=', id)
      .returningAll()
      .executeTakeFirst();
  }

  async delete(id: number) {
    const result = await this.db
      .deleteFrom('users')
      .where('id', '=', id)
      .executeTakeFirst();

    return Number(result.numDeletedRows) > 0;
  }

  async exists(id: number): Promise<boolean> {
    const result = await this.db
      .selectFrom('users')
      .select((eb) => eb.fn.count('id').as('count'))
      .where('id', '=', id)
      .executeTakeFirst();

    return Number(result?.count) > 0;
  }
}

複雑なクエリを持つRepository

export class PostRepository {
  constructor(private db: Kysely<Database>) {}

  // 投稿一覧(著者情報含む)
  async findAll(options: PaginationOptions) {
    return this.db
      .selectFrom('posts')
      .innerJoin('users', 'users.id', 'posts.user_id')
      .select([
        'posts.id',
        'posts.title',
        'posts.slug',
        'posts.excerpt',
        'posts.created_at',
        'users.name as author_name',
        'users.avatar_url as author_avatar'
      ])
      .where('posts.published', '=', true)
      .orderBy('posts.created_at', 'desc')
      .limit(options.pageSize)
      .offset((options.page - 1) * options.pageSize)
      .execute();
  }

  // 投稿詳細(タグ、コメント含む)
  async findBySlugWithRelations(slug: string) {
    // メインデータ取得
    const post = await this.db
      .selectFrom('posts')
      .innerJoin('users', 'users.id', 'posts.user_id')
      .select([
        'posts.id',
        'posts.title',
        'posts.content',
        'posts.created_at',
        'posts.updated_at',
        'users.id as author_id',
        'users.name as author_name',
        'users.bio as author_bio'
      ])
      .where('posts.slug', '=', slug)
      .where('posts.published', '=', true)
      .executeTakeFirst();

    if (!post) return null;

    // タグとコメントを並列取得
    const [tags, comments] = await Promise.all([
      this.db
        .selectFrom('post_tags')
        .innerJoin('tags', 'tags.id', 'post_tags.tag_id')
        .select(['tags.id', 'tags.name', 'tags.slug'])
        .where('post_tags.post_id', '=', post.id)
        .execute(),

      this.db
        .selectFrom('comments')
        .innerJoin('users', 'users.id', 'comments.user_id')
        .select([
          'comments.id',
          'comments.content',
          'comments.created_at',
          'users.name as commenter_name',
          'users.avatar_url as commenter_avatar'
        ])
        .where('comments.post_id', '=', post.id)
        .orderBy('comments.created_at', 'asc')
        .execute()
    ]);

    return { ...post, tags, comments };
  }

  // 人気投稿(ビュー数、コメント数でソート)
  async findPopular(limit: number = 10) {
    return this.db
      .selectFrom('posts')
      .innerJoin('users', 'users.id', 'posts.user_id')
      .select([
        'posts.id',
        'posts.title',
        'posts.slug',
        'posts.view_count',
        'users.name as author_name',
        (eb) => eb
          .selectFrom('comments')
          .select((eb) => eb.fn.count('id').as('comment_count'))
          .whereRef('comments.post_id', '=', 'posts.id')
          .as('comment_count')
      ])
      .where('posts.published', '=', true)
      .orderBy('posts.view_count', 'desc')
      .limit(limit)
      .execute();
  }

  // 関連投稿(同じタグを持つ)
  async findRelated(postId: number, limit: number = 5) {
    return this.db
      .selectFrom('posts as p1')
      .innerJoin('post_tags as pt1', 'pt1.post_id', 'p1.id')
      .innerJoin('post_tags as pt2', 'pt2.tag_id', 'pt1.tag_id')
      .innerJoin('posts as p2', 'p2.id', 'pt2.post_id')
      .select([
        'p2.id',
        'p2.title',
        'p2.slug',
        (eb) => eb.fn.count('pt2.tag_id').as('common_tags')
      ])
      .where('p1.id', '=', postId)
      .where('p2.id', '!=', postId)
      .where('p2.published', '=', true)
      .groupBy(['p2.id', 'p2.title', 'p2.slug'])
      .orderBy('common_tags', 'desc')
      .limit(limit)
      .execute();
  }
}

N+1問題の解決

悪い例(N+1が発生)

// ❌ BAD: N+1クエリ問題
async function getBadUserPosts() {
  // 1. ユーザー一覧を取得
  const users = await db.selectFrom('users').selectAll().execute();

  // 2. 各ユーザーの投稿を取得(N回のクエリ)
  const usersWithPosts = await Promise.all(
    users.map(async (user) => {
      const posts = await db
        .selectFrom('posts')
        .selectAll()
        .where('user_id', '=', user.id)
        .execute();

      return { ...user, posts };
    })
  );

  return usersWithPosts;
}
// → 1 + N クエリ実行

解決策1: JOINを使う

// ✅ GOOD: JOINで1クエリに
async function getGoodUserPosts() {
  const results = await db
    .selectFrom('users')
    .leftJoin('posts', 'posts.user_id', 'users.id')
    .select([
      'users.id',
      'users.name',
      'posts.id as post_id',
      'posts.title as post_title'
    ])
    .execute();

  // グルーピング
  const usersMap = new Map();

  for (const row of results) {
    if (!usersMap.has(row.id)) {
      usersMap.set(row.id, {
        id: row.id,
        name: row.name,
        posts: []
      });
    }

    if (row.post_id) {
      usersMap.get(row.id).posts.push({
        id: row.post_id,
        title: row.post_title
      });
    }
  }

  return Array.from(usersMap.values());
}

解決策2: サブクエリで集約

async function getUsersWithPostCount() {
  return db
    .selectFrom('users')
    .select([
      'users.id',
      'users.name',
      (eb) => eb
        .selectFrom('posts')
        .select((eb) => eb.fn.count('id').as('count'))
        .whereRef('posts.user_id', '=', 'users.id')
        .as('post_count')
    ])
    .execute();
}
// → 1クエリで完結

解決策3: IN句でバッチ取得

async function getUsersWithPostsBatched() {
  // 1. ユーザー取得
  const users = await db.selectFrom('users').selectAll().execute();
  const userIds = users.map(u => u.id);

  // 2. 全投稿を一括取得
  const posts = await db
    .selectFrom('posts')
    .selectAll()
    .where('user_id', 'in', userIds)
    .execute();

  // 3. メモリでグルーピング
  const postsMap = new Map<number, any[]>();
  for (const post of posts) {
    if (!postsMap.has(post.user_id)) {
      postsMap.set(post.user_id, []);
    }
    postsMap.get(post.user_id)!.push(post);
  }

  return users.map(user => ({
    ...user,
    posts: postsMap.get(user.id) || []
  }));
}
// → 2クエリ(1 + 1)

トランザクションパターン

複雑なトランザクション

async function createPostWithTags(
  userId: number,
  postData: {
    title: string;
    content: string;
    tagNames: string[];
  }
) {
  return db.transaction().execute(async (trx) => {
    // 1. 投稿作成
    const post = await trx
      .insertInto('posts')
      .values({
        user_id: userId,
        title: postData.title,
        slug: slugify(postData.title),
        content: postData.content,
        created_at: new Date(),
        updated_at: new Date()
      })
      .returningAll()
      .executeTakeFirst();

    if (!post) throw new Error('Failed to create post');

    // 2. タグを取得または作成
    const tags = await Promise.all(
      postData.tagNames.map(async (name) => {
        // 既存タグを検索
        let tag = await trx
          .selectFrom('tags')
          .selectAll()
          .where('name', '=', name)
          .executeTakeFirst();

        // なければ作成
        if (!tag) {
          tag = await trx
            .insertInto('tags')
            .values({ name, slug: slugify(name) })
            .returningAll()
            .executeTakeFirst();
        }

        return tag;
      })
    );

    // 3. 投稿とタグの関連付け
    if (tags.length > 0) {
      await trx
        .insertInto('post_tags')
        .values(
          tags.map(tag => ({
            post_id: post.id,
            tag_id: tag!.id
          }))
        )
        .execute();
    }

    return { ...post, tags };
  });
}

楽観的ロック

async function updatePostWithOptimisticLock(
  postId: number,
  version: number,
  updates: { title?: string; content?: string }
) {
  return db.transaction().execute(async (trx) => {
    // 現在のバージョンを確認
    const post = await trx
      .selectFrom('posts')
      .select(['id', 'version'])
      .where('id', '=', postId)
      .where('version', '=', version)
      .executeTakeFirst();

    if (!post) {
      throw new Error('Post not found or version mismatch');
    }

    // 更新(バージョンをインクリメント)
    const updated = await trx
      .updateTable('posts')
      .set({
        ...updates,
        version: version + 1,
        updated_at: new Date()
      })
      .where('id', '=', postId)
      .where('version', '=', version)
      .returningAll()
      .executeTakeFirst();

    if (!updated) {
      throw new Error('Concurrent modification detected');
    }

    return updated;
  });
}

テスト戦略

テスト用DBセットアップ

// tests/setup.ts
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';

export async function setupTestDb() {
  const db = new Kysely<Database>({
    dialect: new PostgresDialect({
      pool: new Pool({
        host: 'localhost',
        database: 'test_db',
        user: 'test_user',
        password: 'test_password'
      })
    })
  });

  // マイグレーション実行
  await runMigrations(db);

  return db;
}

export async function teardownTestDb(db: Kysely<Database>) {
  // 全テーブルクリア
  await db.deleteFrom('post_tags').execute();
  await db.deleteFrom('posts').execute();
  await db.deleteFrom('users').execute();

  await db.destroy();
}

Repositoryのユニットテスト

// tests/user.repository.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { setupTestDb, teardownTestDb } from './setup';
import { UserRepository } from '../repositories/user.repository';

describe('UserRepository', () => {
  let db: Kysely<Database>;
  let repo: UserRepository;

  beforeEach(async () => {
    db = await setupTestDb();
    repo = new UserRepository(db);
  });

  afterEach(async () => {
    await teardownTestDb(db);
  });

  it('should create a user', async () => {
    const user = await repo.create({
      name: 'Test User',
      email: 'test@example.com',
      password_hash: 'hashed_password'
    });

    expect(user).toBeDefined();
    expect(user?.name).toBe('Test User');
    expect(user?.email).toBe('test@example.com');
  });

  it('should find user by email', async () => {
    await repo.create({
      name: 'Alice',
      email: 'alice@example.com',
      password_hash: 'hash'
    });

    const user = await repo.findByEmail('alice@example.com');

    expect(user).toBeDefined();
    expect(user?.name).toBe('Alice');
  });

  it('should return null for non-existent user', async () => {
    const user = await repo.findByEmail('nonexistent@example.com');
    expect(user).toBeUndefined();
  });
});

パフォーマンス最適化

クエリのEXPLAIN

async function explainQuery() {
  const query = db
    .selectFrom('posts')
    .innerJoin('users', 'users.id', 'posts.user_id')
    .select(['posts.id', 'posts.title', 'users.name'])
    .where('posts.published', '=', true)
    .compile();

  console.log('SQL:', query.sql);
  console.log('Parameters:', query.parameters);

  // EXPLAINを実行
  const plan = await db
    .executeQuery({
      ...query,
      sql: `EXPLAIN ANALYZE ${query.sql}`
    });

  console.log('Query Plan:', plan.rows);
}

インデックスヒントの追加

// マイグレーションでインデックス作成
await db.schema
  .createIndex('posts_user_id_published_idx')
  .on('posts')
  .columns(['user_id', 'published'])
  .execute();

// 複合インデックスを活用
const posts = await db
  .selectFrom('posts')
  .selectAll()
  .where('user_id', '=', userId)
  .where('published', '=', true)
  .execute();

バッチ処理

async function batchInsertPosts(posts: Array<{
  user_id: number;
  title: string;
  content: string;
}>) {
  // 1000件ずつバッチ挿入
  const batchSize = 1000;

  for (let i = 0; i < posts.length; i += batchSize) {
    const batch = posts.slice(i, i + batchSize);

    await db
      .insertInto('posts')
      .values(
        batch.map(post => ({
          ...post,
          created_at: new Date(),
          updated_at: new Date()
        }))
      )
      .execute();
  }
}

まとめ

Kyselyは実践的なアプリケーション開発で威力を発揮します。

主要なベストプラクティス

  1. 動的クエリ - フィルタリング、ソート、ページネーションを柔軟に構築
  2. Repositoryパターン - ビジネスロジックとデータアクセスを分離
  3. N+1問題対策 - JOIN、サブクエリ、バッチ取得を活用
  4. トランザクション - 複雑な操作を安全に実行
  5. テスト - 型安全性を活かしたテスト戦略

Kyselyは、SQLの柔軟性TypeScriptの型安全性を両立した、モダンなアプリケーション開発に最適なツールです。