Kysely

Kyselyは、TypeScript専用に設計された型安全なSQLクエリビルダーです。RAW SQLの力とTypeScriptの型チェックを組み合わせ、複雑なクエリ作成に特化した革新的なアプローチを採用。従来のORMとは異なり、SQLの制御を重視する開発者のために、型安全性を犠牲にすることなく直接的なSQL操作を可能にします。データベーススキーマからの型推論により、存在しないテーブルやカラムへのアクセスをコンパイル時に検出し、実行時エラーを大幅に削減。関数型プログラミングパラダイムを活用した流暢なAPIにより、複雑なアナリティクスクエリやデータウェアハウス操作も自然に記述できます。

ORMTypeScriptSQL優先型安全クエリビルダー関数型

GitHub概要

kysely-org/kysely

A type-safe TypeScript SQL query builder

スター12,333
ウォッチ29
フォーク324
作成日:2021年2月22日
言語:TypeScript
ライセンス:MIT License

トピックス

browserbundatabasedenomicrosoft-sql-servermssqlmysqlnodejspostgresqlquery-buildersqlsqlitetype-safetypescript

スター履歴

kysely-org/kysely Star History
データ取得日時: 2025/7/16 22:58

ライブラリ

Kysely

概要

Kyselyは、TypeScript専用に設計された型安全なSQLクエリビルダーです。RAW SQLの力とTypeScriptの型チェックを組み合わせ、複雑なクエリ作成に特化した革新的なアプローチを採用。従来のORMとは異なり、SQLの制御を重視する開発者のために、型安全性を犠牲にすることなく直接的なSQL操作を可能にします。データベーススキーマからの型推論により、存在しないテーブルやカラムへのアクセスをコンパイル時に検出し、実行時エラーを大幅に削減。関数型プログラミングパラダイムを活用した流暢なAPIにより、複雑なアナリティクスクエリやデータウェアハウス操作も自然に記述できます。

詳細

Kysely 2025年版は、TypeScript開発者の間で「SQL制御と型安全性の理想的バランス」として急速に注目を集めています。PostgreSQL、MySQL、SQLiteに加え、Cloudflare D1、PlanetScale等のモダンデータベースサービスとの統合により、エッジ環境やサーバーレスアーキテクチャでの採用が拡大中。型レベルでのクエリ構築により、IntelliSenseによる完璧な補完とリファクタリング安全性を実現。ゼロ依存関係設計によりバンドルサイズを最小化し、パフォーマンス重視のアプリケーションに最適。Expression Builder、Conditional Query Building、Dynamic Query Constructionなど高度な機能により、複雑なビジネスロジックを型安全に実装可能です。

主な特徴

  • 完全な型安全性: TypeScript型システムと完全統合したSQL構築
  • ゼロ依存関係: 軽量でサーバーレス環境に最適化
  • Expression Builder: 複雑なSQL表現の型安全な構築
  • 動的クエリ: 条件に応じたクエリの柔軟な構築
  • マルチDB対応: 主要データベースとクラウドサービス完全サポート
  • 最新TypeScript機能: Template Literal Types等の活用

メリット・デメリット

メリット

  • TypeScript型システムによる完璧な型安全性とIntelliSense補完
  • ゼロ依存関係による軽量性とサーバーレス環境での優位性
  • SQL知識を直接活かせる透明性の高いクエリ構築
  • 複雑なアナリティクスクエリの型安全な実装
  • モダンデータベースサービスとの優れた統合性
  • 関数型アプローチによる予測可能で保守性の高いコード

デメリット

  • SQL知識が必須で初心者には学習コストが高い
  • ORM的な自動化機能(マイグレーション、リレーション管理)が限定的
  • Active Recordパターンなどの高レベル抽象化は非対応
  • スキーマファーストアプローチには追加ツールが必要
  • 複雑な型定義により、エラーメッセージが理解困難な場合がある
  • エコシステムが比較的新しく、サードパーティツールが限定的

参考ページ

書き方の例

インストールとセットアップ

# Kyselyのインストール
npm install kysely

# データベースドライバーのインストール
# PostgreSQL
npm install pg
npm install --save-dev @types/pg

# MySQL
npm install mysql2

# SQLite (Node.js)
npm install better-sqlite3
npm install --save-dev @types/better-sqlite3

# Cloudflare D1
npm install @cloudflare/workers-types

# TypeScript必須
npm install --save-dev typescript
// database.ts - データベース型定義
export interface Database {
  person: PersonTable;
  pet: PetTable;
  movie: MovieTable;
  person_movie: PersonMovieTable;
}

export interface PersonTable {
  id: Generated<number>;
  first_name: string;
  last_name: string | null;
  email: string;
  age: number;
  gender: 'male' | 'female' | 'other';
  created_at: ColumnType<Date, string | undefined, never>;
  updated_at: ColumnType<Date, string | undefined, string | undefined>;
  preferences: JSONColumnType<{
    theme: 'light' | 'dark';
    language: string;
    notifications: boolean;
  }>;
}

export interface PetTable {
  id: Generated<number>;
  name: string;
  species: 'dog' | 'cat' | 'bird' | 'other';
  owner_id: number;
  birth_date: Date | null;
}

export interface MovieTable {
  id: Generated<number>;
  title: string;
  release_date: Date;
  rating: number;
  genre: string[];
}

export interface PersonMovieTable {
  person_id: number;
  movie_id: number;
  rating: number | null;
  watched_at: Date;
}

// db.ts - Kyselyインスタンス設定
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import { Database } from './database';

const dialect = new PostgresDialect({
  pool: new Pool({
    database: 'test',
    host: 'localhost',
    user: 'admin',
    password: 'admin',
    port: 5432,
    max: 10,
  }),
});

export const db = new Kysely<Database>({
  dialect,
});

// 型ヘルパー
export type Person = Selectable<PersonTable>;
export type NewPerson = Insertable<PersonTable>;
export type PersonUpdate = Updateable<PersonTable>;

基本的なクエリ操作

import { db } from './db';

// SELECT - 基本的な検索
async function basicQueries() {
  // 全件取得
  const allPersons = await db
    .selectFrom('person')
    .selectAll()
    .execute();

  // 特定カラムの選択
  const names = await db
    .selectFrom('person')
    .select(['first_name', 'last_name', 'email'])
    .where('age', '>=', 18)
    .orderBy('last_name')
    .execute();

  // 条件付きクエリ
  const adults = await db
    .selectFrom('person')
    .selectAll()
    .where('age', '>=', 18)
    .where('email', 'like', '%@example.com')
    .limit(10)
    .execute();

  // 集約関数
  const stats = await db
    .selectFrom('person')
    .select([
      (eb) => eb.fn.count<number>('id').as('total_count'),
      (eb) => eb.fn.avg<number>('age').as('average_age'),
      (eb) => eb.fn.min<number>('age').as('min_age'),
      (eb) => eb.fn.max<number>('age').as('max_age'),
    ])
    .executeTakeFirstOrThrow();

  console.log(`総数: ${stats.total_count}, 平均年齢: ${stats.average_age}`);

  return { allPersons, names, adults, stats };
}

// INSERT - データ挿入
async function insertOperations() {
  // 単一レコード挿入
  const newPerson = await db
    .insertInto('person')
    .values({
      first_name: '太郎',
      last_name: '田中',
      email: '[email protected]',
      age: 30,
      gender: 'male',
      preferences: {
        theme: 'dark',
        language: 'ja',
        notifications: true
      }
    })
    .returning(['id', 'first_name', 'last_name'])
    .executeTakeFirstOrThrow();

  // 複数レコード挿入
  const newPersons = await db
    .insertInto('person')
    .values([
      {
        first_name: '花子',
        last_name: '佐藤',
        email: '[email protected]',
        age: 25,
        gender: 'female'
      },
      {
        first_name: '一郎',
        last_name: '鈴木',
        email: '[email protected]',
        age: 35,
        gender: 'male'
      }
    ])
    .returning('id')
    .execute();

  // ON CONFLICT (PostgreSQL)
  const upsertedPerson = await db
    .insertInto('person')
    .values({
      email: '[email protected]',
      first_name: 'Updated',
      last_name: 'Name',
      age: 28,
      gender: 'other'
    })
    .onConflict((oc) => oc
      .column('email')
      .doUpdateSet({
        first_name: (eb) => eb.ref('excluded.first_name'),
        last_name: (eb) => eb.ref('excluded.last_name'),
        age: (eb) => eb.ref('excluded.age')
      })
    )
    .returning(['id', 'first_name'])
    .executeTakeFirst();

  return { newPerson, newPersons, upsertedPerson };
}

// UPDATE - データ更新
async function updateOperations() {
  // 条件付き更新
  const updateResult = await db
    .updateTable('person')
    .set({
      age: (eb) => eb('age', '+', 1),
      updated_at: new Date().toISOString()
    })
    .where('id', '=', 1)
    .returning(['id', 'age'])
    .executeTakeFirst();

  // 複雑な条件での更新
  await db
    .updateTable('person')
    .set({
      'preferences.theme': 'light'
    })
    .where((eb) => eb.or([
      eb('age', '>', 60),
      eb('preferences', '->', 'notifications').eq(false)
    ]))
    .execute();

  // JOINを含む更新
  await db
    .updateTable('person')
    .set({
      last_name: 'VIP'
    })
    .from('pet')
    .where('person.id', '=', (eb) => eb.ref('pet.owner_id'))
    .where('pet.species', '=', 'dog')
    .execute();

  return updateResult;
}

// DELETE - データ削除
async function deleteOperations() {
  // 条件付き削除
  const deleteResult = await db
    .deleteFrom('person')
    .where('age', '<', 0)
    .returning('id')
    .execute();

  // 複雑な条件での削除
  await db
    .deleteFrom('person')
    .where((eb) => eb.and([
      eb('created_at', '<', new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)),
      eb.not(eb.exists(
        eb.selectFrom('pet')
          .select('id')
          .where('owner_id', '=', eb.ref('person.id'))
      ))
    ]))
    .execute();

  return deleteResult;
}

高度なクエリと型安全操作

import { ExpressionBuilder, sql } from 'kysely';

// JOINとサブクエリ
async function advancedQueries() {
  // INNER JOIN
  const personsWithPets = await db
    .selectFrom('person')
    .innerJoin('pet', 'pet.owner_id', 'person.id')
    .select([
      'person.first_name',
      'person.last_name',
      'pet.name as pet_name',
      'pet.species'
    ])
    .where('pet.species', '=', 'dog')
    .execute();

  // LEFT JOIN with aggregation
  const personsWithPetCount = await db
    .selectFrom('person')
    .leftJoin('pet', 'pet.owner_id', 'person.id')
    .select([
      'person.id',
      'person.first_name',
      'person.last_name',
      (eb) => eb.fn.count<number>('pet.id').as('pet_count')
    ])
    .groupBy(['person.id', 'person.first_name', 'person.last_name'])
    .having((eb) => eb.fn.count('pet.id'), '>', 0)
    .execute();

  // 相関サブクエリ
  const personsWithLatestMovie = await db
    .selectFrom('person')
    .select([
      'person.first_name',
      'person.last_name',
      (eb) => eb
        .selectFrom('movie')
        .innerJoin('person_movie', 'person_movie.movie_id', 'movie.id')
        .select('movie.title')
        .where('person_movie.person_id', '=', eb.ref('person.id'))
        .orderBy('movie.release_date', 'desc')
        .limit(1)
        .as('latest_movie')
    ])
    .execute();

  return { personsWithPets, personsWithPetCount, personsWithLatestMovie };
}

// Expression Builderの活用
async function expressionBuilderExamples() {
  // 複雑な条件構築
  const complexCondition = await db
    .selectFrom('person')
    .selectAll()
    .where((eb) => eb.or([
      eb.and([
        eb('age', '>=', 18),
        eb('age', '<=', 65)
      ]),
      eb('gender', '=', 'other'),
      eb.exists(
        eb.selectFrom('pet')
          .select('id')
          .where('owner_id', '=', eb.ref('person.id'))
          .where('species', 'in', ['dog', 'cat'])
      )
    ]))
    .execute();

  // 計算フィールド
  const personsWithComputedFields = await db
    .selectFrom('person')
    .select([
      'first_name',
      'last_name',
      (eb) => eb.fn('concat', ['first_name', eb.val(' '), 'last_name']).as('full_name'),
      (eb) => eb.case()
        .when('age', '<', 20).then('Young')
        .when('age', '<', 60).then('Adult')
        .else('Senior')
        .end()
        .as('age_group'),
      (eb) => eb('age', '*', 365.25).as('age_in_days')
    ])
    .execute();

  // JSON操作
  const jsonQueries = await db
    .selectFrom('person')
    .select([
      'first_name',
      'last_name',
      (eb) => eb.ref('preferences', '->', 'theme').as('theme'),
      (eb) => eb.ref('preferences', '->>', 'language').as('language')
    ])
    .where((eb) => eb.ref('preferences', '->', 'notifications').eq(true))
    .execute();

  return { complexCondition, personsWithComputedFields, jsonQueries };
}

// Window関数とCTE
async function windowFunctionsAndCte() {
  // Window関数
  const rankedPersons = await db
    .selectFrom('person')
    .select([
      'first_name',
      'last_name',
      'age',
      (eb) => eb.fn('row_number').over(
        ob => ob.orderBy('age', 'desc')
      ).as('age_rank'),
      (eb) => eb.fn('rank').over(
        ob => ob.partitionBy('gender').orderBy('age', 'desc')
      ).as('gender_age_rank'),
      (eb) => eb.fn('avg', ['age']).over(
        ob => ob.partitionBy('gender')
      ).as('avg_age_by_gender')
    ])
    .execute();

  // Common Table Expression (WITH)
  const cteQuery = await db
    .with('adult_persons', (db) => db
      .selectFrom('person')
      .select(['id', 'first_name', 'last_name', 'age'])
      .where('age', '>=', 18)
    )
    .with('pet_counts', (db) => db
      .selectFrom('pet')
      .select([
        'owner_id',
        (eb) => eb.fn.count<number>('id').as('pet_count')
      ])
      .groupBy('owner_id')
    )
    .selectFrom('adult_persons as ap')
    .leftJoin('pet_counts as pc', 'pc.owner_id', 'ap.id')
    .select([
      'ap.first_name',
      'ap.last_name',
      'ap.age',
      (eb) => eb.fn.coalesce('pc.pet_count', eb.lit(0)).as('pet_count')
    ])
    .execute();

  return { rankedPersons, cteQuery };
}

// 動的クエリ構築
async function dynamicQueryBuilding() {
  interface SearchFilters {
    name?: string;
    minAge?: number;
    maxAge?: number;
    gender?: 'male' | 'female' | 'other';
    hasPets?: boolean;
  }

  async function searchPersons(filters: SearchFilters) {
    let query = db
      .selectFrom('person')
      .selectAll();

    // 名前フィルタ
    if (filters.name) {
      query = query.where((eb) => eb.or([
        eb('first_name', 'ilike', `%${filters.name}%`),
        eb('last_name', 'ilike', `%${filters.name}%`)
      ]));
    }

    // 年齢フィルタ
    if (filters.minAge !== undefined) {
      query = query.where('age', '>=', filters.minAge);
    }
    if (filters.maxAge !== undefined) {
      query = query.where('age', '<=', filters.maxAge);
    }

    // 性別フィルタ
    if (filters.gender) {
      query = query.where('gender', '=', filters.gender);
    }

    // ペット所有フィルタ
    if (filters.hasPets !== undefined) {
      if (filters.hasPets) {
        query = query.where((eb) => eb.exists(
          eb.selectFrom('pet')
            .select('id')
            .where('owner_id', '=', eb.ref('person.id'))
        ));
      } else {
        query = query.where((eb) => eb.not(eb.exists(
          eb.selectFrom('pet')
            .select('id')
            .where('owner_id', '=', eb.ref('person.id'))
        )));
      }
    }

    return query.execute();
  }

  // 使用例
  const searchResults = await searchPersons({
    name: '田中',
    minAge: 20,
    maxAge: 50,
    gender: 'male',
    hasPets: true
  });

  return searchResults;
}

トランザクションとバッチ処理

// トランザクション管理
async function transactionExamples() {
  // 基本的なトランザクション
  const result = await db.transaction().execute(async (trx) => {
    // 新しい人を作成
    const person = await trx
      .insertInto('person')
      .values({
        first_name: 'トランザクション',
        last_name: 'テスト',
        email: '[email protected]',
        age: 30,
        gender: 'other'
      })
      .returning(['id', 'first_name'])
      .executeTakeFirstOrThrow();

    // ペットを追加
    const pet = await trx
      .insertInto('pet')
      .values({
        name: 'トランザクションペット',
        species: 'dog',
        owner_id: person.id,
        birth_date: new Date('2020-01-01')
      })
      .returning(['id', 'name'])
      .executeTakeFirstOrThrow();

    // 映画の関連付け
    await trx
      .insertInto('person_movie')
      .values({
        person_id: person.id,
        movie_id: 1,
        rating: 5,
        watched_at: new Date()
      })
      .execute();

    return { person, pet };
  });

  console.log('トランザクション完了:', result);
  return result;
}

// バッチ操作とパフォーマンス最適化
async function batchOperations() {
  // バッチインサート
  const batchInsertData = Array.from({ length: 1000 }, (_, i) => ({
    first_name: `User${i}`,
    last_name: `Batch`,
    email: `user${i}@batch.com`,
    age: Math.floor(Math.random() * 80) + 18,
    gender: ['male', 'female', 'other'][Math.floor(Math.random() * 3)] as const
  }));

  const batchResult = await db
    .insertInto('person')
    .values(batchInsertData)
    .returning('id')
    .execute();

  console.log(`${batchResult.length}件のレコードを挿入しました`);

  // ストリーミング処理(大量データ)
  const streamProcessor = db
    .selectFrom('person')
    .selectAll()
    .where('age', '>', 50)
    .stream();

  let processedCount = 0;
  for await (const person of streamProcessor) {
    // 各レコードを処理
    console.log(`処理中: ${person.first_name} ${person.last_name}`);
    processedCount++;
    
    if (processedCount % 100 === 0) {
      console.log(`${processedCount}件処理完了`);
    }
  }

  return { batchCount: batchResult.length, streamProcessed: processedCount };
}

// クエリコンパイルと再利用
async function compiledQueries() {
  // クエリのコンパイル
  const findPersonByIdQuery = db
    .selectFrom('person')
    .selectAll()
    .where('id', '=', sql.placeholder<number>())
    .compile();

  // コンパイル済みクエリの再実行
  const person1 = await findPersonByIdQuery.execute({ id: 1 });
  const person2 = await findPersonByIdQuery.execute({ id: 2 });

  // 複雑なクエリのコンパイル
  const searchQuery = db
    .selectFrom('person')
    .leftJoin('pet', 'pet.owner_id', 'person.id')
    .select([
      'person.id',
      'person.first_name',
      'person.last_name',
      (eb) => eb.fn.count<number>('pet.id').as('pet_count')
    ])
    .where('person.age', '>=', sql.placeholder<number>())
    .where('person.age', '<=', sql.placeholder<number>())
    .groupBy(['person.id', 'person.first_name', 'person.last_name'])
    .compile();

  const searchResults = await searchQuery.execute({
    minAge: 20,
    maxAge: 65
  });

  return { person1, person2, searchResults };
}

エラーハンドリングとデバッグ

import { DatabaseError, sql } from 'kysely';

// エラーハンドリング
async function errorHandlingExamples() {
  try {
    // 制約違反エラー
    await db
      .insertInto('person')
      .values({
        first_name: 'テスト',
        last_name: 'ユーザー',
        email: '[email protected]', // 既存のメール
        age: 25,
        gender: 'male'
      })
      .execute();
  } catch (error) {
    if (error instanceof DatabaseError) {
      switch (error.code) {
        case '23505': // Unique violation (PostgreSQL)
          console.log('一意制約違反:', error.message);
          break;
        case '23503': // Foreign key violation
          console.log('外部キー制約違反:', error.message);
          break;
        case '23502': // Not null violation
          console.log('NULL制約違反:', error.message);
          break;
        default:
          console.log('データベースエラー:', error.code, error.message);
      }
    } else {
      console.error('予期しないエラー:', error);
    }
  }

  // 型安全なエラーハンドリング
  const safeInsert = async (personData: NewPerson) => {
    try {
      const result = await db
        .insertInto('person')
        .values(personData)
        .returning(['id', 'first_name', 'last_name'])
        .executeTakeFirstOrThrow();
      
      return { success: true, data: result };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      };
    }
  };

  const insertResult = await safeInsert({
    first_name: 'セーフ',
    last_name: 'インサート',
    email: '[email protected]',
    age: 30,
    gender: 'other'
  });

  if (insertResult.success) {
    console.log('挿入成功:', insertResult.data);
  } else {
    console.log('挿入失敗:', insertResult.error);
  }
}

// クエリデバッグ
async function debuggingExamples() {
  // SQL出力の有効化
  const debugDb = db.withPlugins([
    {
      transformQuery(args) {
        console.log('実行SQL:', args.query.sql);
        console.log('パラメータ:', args.query.parameters);
        return args.query;
      }
    }
  ]);

  await debugDb
    .selectFrom('person')
    .selectAll()
    .where('age', '>', 30)
    .execute();

  // クエリ実行時間測定
  const startTime = Date.now();
  const result = await db
    .selectFrom('person')
    .leftJoin('pet', 'pet.owner_id', 'person.id')
    .select([
      'person.first_name',
      'person.last_name',
      (eb) => eb.fn.count<number>('pet.id').as('pet_count')
    ])
    .groupBy(['person.id', 'person.first_name', 'person.last_name'])
    .execute();
  
  const executionTime = Date.now() - startTime;
  console.log(`クエリ実行時間: ${executionTime}ms, 結果件数: ${result.length}`);

  // 型アサーション使用時のデバッグ
  const typedQuery = db
    .selectFrom('person')
    .select([
      'first_name',
      'last_name',
      (eb) => eb.ref('preferences', '->', 'theme').$asScalar().as('theme')
    ])
    .where((eb) => eb.ref('preferences', '->', 'notifications').eq(true));

  // クエリの型チェック
  type QueryResult = InferResult<typeof typedQuery>[0];
  // QueryResult の型: { first_name: string; last_name: string | null; theme: unknown }

  const typedResult = await typedQuery.execute();
  return typedResult;
}

// パフォーマンス監視
async function performanceMonitoring() {
  // クエリ実行時間の統計
  class QueryStatsPlugin {
    private stats: Array<{ sql: string; time: number }> = [];

    transformQuery(args: any) {
      const startTime = Date.now();
      
      return {
        ...args.query,
        then: (callback: any) => {
          return args.query.then((result: any) => {
            const endTime = Date.now();
            this.stats.push({
              sql: args.query.sql,
              time: endTime - startTime
            });
            
            return callback ? callback(result) : result;
          });
        }
      };
    }

    getStats() {
      return {
        totalQueries: this.stats.length,
        averageTime: this.stats.reduce((sum, stat) => sum + stat.time, 0) / this.stats.length,
        slowestQuery: this.stats.reduce((slowest, stat) => 
          stat.time > slowest.time ? stat : slowest, { sql: '', time: 0 })
      };
    }
  }

  const statsPlugin = new QueryStatsPlugin();
  const monitoredDb = db.withPlugins([statsPlugin]);

  // 複数のクエリを実行
  await monitoredDb.selectFrom('person').selectAll().execute();
  await monitoredDb.selectFrom('pet').selectAll().execute();
  
  const stats = statsPlugin.getStats();
  console.log('クエリ統計:', stats);

  return stats;
}