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