Kysely ile Type-Safe SQL Query Builder: Tam Rehber
Veritabanı işlemleri, her backend projesinin kalbidir. Yıllardır geliştiriciler olarak iki uç arasında gidip geldik: Ham SQL yazmanın esnekliği ama hata riski ya da ORM'lerin güvenliği ama performans ve kontrol kaybı. Kysely (okunuşu: "key-seh-lee"), bu iki dünyanın en iyisini bir araya getiren, TypeScript-first bir SQL query builder olarak bu denklemi kökünden değiştiriyor.
Kysely Nedir ve Neden İhtiyacımız Var?
Kysely, Sami Koskimäki tarafından geliştirilen, sıfır bağımlılıklı, hafif ve tamamen type-safe bir SQL query builder'dır. ORM değildir — bu çok önemli bir ayrım. Kysely, SQL sorgularınızı TypeScript tip sistemiyle sarmalayarak yazmanızı sağlar. Yanlış bir tablo adı, sütun adı veya veri tipi kullandığınızda daha derleme aşamasında hata alırsınız.
ORM vs Query Builder: Fark Nedir?
| Özellik | ORM (Prisma, TypeORM) | Query Builder (Kysely) |
|---|---|---|
| Soyutlama seviyesi | Yüksek | Düşük-Orta |
| SQL kontrolü | Sınırlı | Tam |
| Öğrenme eğrisi | Framework'ü öğrenmeniz gerekir | SQL bilmeniz yeterli |
| Migration | Genellikle dahili | Dahili (kysely-migration) |
| Performans | Overhead olabilir | SQL'e çok yakın |
| Tip güvenliği | Var | Var (daha granüler) |
Kysely'in felsefesi basittir: "SQL zaten harika bir dil. Onu TypeScript ile güvenli hale getirelim, ama değiştirmeyelim."
Kurulum ve İlk Yapılandırma
Hadi bir projeye Kysely'i entegre edelim. Bu örnekte PostgreSQL kullanacağız, ancak Kysely MySQL, SQLite ve MSSQL'i de destekler.
npm install kysely pg
npm install --save-dev @types/pgVeritabanı Şemasını TypeScript ile Tanımlama
Kysely'in büyüsü burada başlar. Veritabanınızın şemasını bir TypeScript interface olarak tanımlarsınız:
import { Generated, Insertable, Selectable, Updateable, ColumnType } from 'kysely'
// Tablo tanımları
interface UsersTable {
id: Generated<number>
email: string
username: string
password_hash: string
is_active: boolean
created_at: ColumnType<Date, string | undefined, never>
}
interface PostsTable {
id: Generated<number>
title: string
content: string
author_id: number
status: 'draft' | 'published' | 'archived'
view_count: number
published_at: Date | null
created_at: Generated<Date>
}
interface CommentsTable {
id: Generated<number>
post_id: number
user_id: number
body: string
created_at: Generated<Date>
}
// Ana veritabanı interface'i
interface Database {
users: UsersTable
posts: PostsTable
comments: CommentsTable
}
// Yardımcı tipler — bunları servis katmanında kullanacaksınız
type User = Selectable<UsersTable>
type NewUser = Insertable<UsersTable>
type UserUpdate = Updateable<UsersTable>
type Post = Selectable<PostsTable>
type NewPost = Insertable<PostsTable>
type PostUpdate = Updateable<PostsTable>Buradaki Generated, ColumnType, Selectable, Insertable ve Updateable tipleri Kysely'in en güçlü özelliklerinden biridir:
- Generated
: Veritabanı tarafından otomatik üretilen sütunlar (auto-increment ID, default timestamp) - ColumnType<SelectType, InsertType, UpdateType>: Select, insert ve update işlemlerinde farklı tiplere sahip sütunlar
- Selectable
: SELECT sorgularında dönen tip - Insertable
: INSERT sorgularında kabul edilen tip ( Generatedalanlar opsiyonel olur) - Updateable
: UPDATE sorgularında kabul edilen tip (tüm alanlar opsiyonel olur)
Kysely Instance'ını Oluşturma
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 10, // connection pool size
}),
}),
})
export default dbBu db nesnesi artık tüm sorgularınız için kullanacağınız, tam tip güvenliğine sahip gateway'inizdir.
Temel CRUD İşlemleri
SELECT Sorguları
// Tüm aktif kullanıcıları getir
const activeUsers = await db
.selectFrom('users')
.where('is_active', '=', true)
.select(['id', 'email', 'username'])
.execute()
// Tip: { id: number; email: string; username: string }[]
// Tek bir kullanıcı getir
const user = await db
.selectFrom('users')
.where('id', '=', userId)
.selectAll()
.executeTakeFirst()
// Tip: User | undefined
// executeTakeFirstOrThrow — bulamazsa hata fırlatır
const user = await db
.selectFrom('users')
.where('email', '=', 'john@example.com')
.selectAll()
.executeTakeFirstOrThrow()
// Tip: User (undefined yok!)Dikkat edin: select(['id', 'email', 'username']) yazdığınızda, dönen objenin tipi otomatik olarak sadece bu üç alanı içerir. Olmayan bir sütun adı yazarsanız derleme hatası alırsınız. Bu, Kysely'in gücüdür.
INSERT İşlemleri
// Tek kayıt ekleme
const newUser = await db
.insertInto('users')
.values({
email: 'jane@example.com',
username: 'jane_doe',
password_hash: hashedPassword,
is_active: true,
})
.returningAll()
.executeTakeFirstOrThrow()
// Çoklu kayıt ekleme
await db
.insertInto('posts')
.values([
{
title: 'İlk Yazı',
content: 'Merhaba dünya!',
author_id: newUser.id,
status: 'published',
view_count: 0,
published_at: new Date(),
},
{
title: 'Taslak Yazı',
content: 'Henüz hazır değil...',
author_id: newUser.id,
status: 'draft',
view_count: 0,
published_at: null,
},
])
.execute()status alanına 'invalid_status' yazmayı deneyin — TypeScript anında hata verecektir çünkü şemada 'draft' | 'published' | 'archived' olarak tanımladık.
UPDATE İşlemleri
// Basit update
await db
.updateTable('posts')
.set({ status: 'published', published_at: new Date() })
.where('id', '=', postId)
.execute()
// Dinamik update — koşullu alanlar
const updateData: PostUpdate = {}
if (newTitle) updateData.title = newTitle
if (newContent) updateData.content = newContent
await db
.updateTable('posts')
.set(updateData)
.where('id', '=', postId)
.where('author_id', '=', currentUserId) // yetkilendirme kontrolü
.execute()
// Mevcut değere göre artırma
await db
.updateTable('posts')
.set((eb) => ({
view_count: eb('view_count', '+', 1),
}))
.where('id', '=', postId)
.execute()DELETE İşlemleri
// Basit silme
const result = await db
.deleteFrom('comments')
.where('id', '=', commentId)
.where('user_id', '=', currentUserId)
.executeTakeFirst()
// Kaç satır silindiğini kontrol et
console.log(`${result.numDeletedRows} yorum silindi`)İleri Seviye Sorgular
JOIN İşlemleri
Kysely'de JOIN'ler de tamamen type-safe'dir:
// Yazıları yazarlarıyla birlikte getir
const postsWithAuthors = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.id',
'posts.title',
'posts.status',
'posts.published_at',
'users.username as author_name',
'users.email as author_email',
])
.where('posts.status', '=', 'published')
.orderBy('posts.published_at', 'desc')
.limit(20)
.execute()
// Tip otomatik çıkarılır!
// Yorumlarla birlikte bir yazıyı getir
const postWithComments = await db
.selectFrom('posts')
.leftJoin('comments', 'comments.post_id', 'posts.id')
.leftJoin('users', 'users.id', 'comments.user_id')
.select([
'posts.id as post_id',
'posts.title',
'posts.content',
'comments.body as comment_body',
'users.username as commenter_name',
])
.where('posts.id', '=', postId)
.execute()Karmaşık WHERE Koşulları
// OR ve AND kombinasyonları
const results = await db
.selectFrom('posts')
.selectAll()
.where((eb) =>
eb.or([
eb('status', '=', 'published'),
eb.and([
eb('status', '=', 'draft'),
eb('author_id', '=', currentUserId),
]),
])
)
.execute()
// IN operatörü
const specificPosts = await db
.selectFrom('posts')
.selectAll()
.where('id', 'in', [1, 2, 3, 4, 5])
.execute()
// LIKE operatörü
const searchResults = await db
.selectFrom('posts')
.selectAll()
.where('title', 'like', `%${searchTerm}%`)
.execute()Subquery ve Expression Builder
// Subquery ile yorum sayısını getir
const postsWithCommentCount = await db
.selectFrom('posts')
.select((eb) => [
'posts.id',
'posts.title',
eb
.selectFrom('comments')
.select(eb.fn.count('comments.id').as('count'))
.whereRef('comments.post_id', '=', 'posts.id')
.as('comment_count'),
])
.execute()Aggregate Fonksiyonları
// Kullanıcı başına istatistikler
const userStats = await db
.selectFrom('users')
.leftJoin('posts', 'posts.author_id', 'users.id')
.select((eb) => [
'users.id',
'users.username',
eb.fn.count('posts.id').as('post_count'),
eb.fn.sum('posts.view_count').as('total_views'),
eb.fn.max('posts.published_at').as('last_published'),
])
.groupBy(['users.id', 'users.username'])
.having(eb.fn.count('posts.id'), '>', 0)
.orderBy('post_count', 'desc')
.execute()Transaction Yönetimi
// Bir yazı oluştururken ilk yorumu da ekleyelim — atomic işlem
const result = await db.transaction().execute(async (trx) => {
const post = await trx
.insertInto('posts')
.values({
title: 'Transaction Testi',
content: 'Bu bir transaction içinde oluşturuldu',
author_id: userId,
status: 'published',
view_count: 0,
published_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow()
const comment = await trx
.insertInto('comments')
.values({
post_id: post.id,
user_id: userId,
body: 'İlk yorum — yazar tarafından',
})
.returningAll()
.executeTakeFirstOrThrow()
return { post, comment }
})
// Herhangi bir hata olursa tüm işlem geri alınırMigration Sistemi
Kysely'in dahili migration sistemi de oldukça pratiktir:
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('username', 'varchar(100)', (col) => col.notNull())
.addColumn('password_hash', 'varchar(255)', (col) => col.notNull())
.addColumn('is_active', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`now()`)
)
.execute()
// Index ekleme
await db.schema
.createIndex('idx_users_email')
.on('users')
.column('email')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute()
}Kysely ile Tip Güvenli Yardımcı Fonksiyonlar
Gerçek projelerde tekrarlayan sorgu kalıplarını yardımcı fonksiyonlar olarak soyutlayabilirsiniz:
// Sayfalama yardımcısı
interface PaginationParams {
page: number
pageSize: number
}
async function getPaginatedPosts(
params: PaginationParams & { status?: Post['status'] }
) {
const { page, pageSize, status } = params
const offset = (page - 1) * pageSize
let query = db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.id',
'posts.title',
'posts.status',
'posts.view_count',
'posts.published_at',
'users.username as author_name',
])
.orderBy('posts.created_at', 'desc')
.limit(pageSize)
.offset(offset)
if (status) {
query = query.where('posts.status', '=', status)
}
const [posts, countResult] = await Promise.all([
query.execute(),
db
.selectFrom('posts')
.select(db.fn.count('id').as('total'))
.$if(!!status, (qb) => qb.where('status', '=', status!))
.executeTakeFirstOrThrow(),
])
return {
data: posts,
pagination: {
page,
pageSize,
total: Number(countResult.total),
totalPages: Math.ceil(Number(countResult.total) / pageSize),
},
}
}Kysely'in Avantajları ve Dezavantajları
Avantajları
- Tam tip güvenliği: Derleme zamanında SQL hatalarını yakalar
- Sıfır bağımlılık: Çok hafif (~8KB gzipped)
- SQL'e yakınlık: Oluşturulan SQL'i tahmin etmek kolay
- Autocompletion: IDE'niz tablo ve sütun adlarını önerir
- Tree-shakeable: Kullanmadığınız özellikler bundle'a eklenmez
Dezavantajları
- Relation yönetimi yok: ORM'ler gibi otomatik relation loading yoktur
- Daha fazla SQL bilgisi gerektirir: SQL bilmeden kullanmak zordur
- Şema senkronizasyonu manuel: Veritabanı değiştiğinde tipleri güncellemeniz gerekir (kysely-codegen bu sorunu çözer)
Bonus: kysely-codegen ile Otomatik Tip Üretimi
Mevcut veritabanınızdan otomatik tip üretmek için:
npm install --save-dev kysely-codegen
npx kysely-codegen --dialect postgres --url postgres://user:pass@localhost/mydbBu komut veritabanınızı inceleyip TypeScript tiplerini otomatik olarak üretir.
Sonuç
Kysely, TypeScript ekosistemindeki veritabanı araçları arasında benzersiz bir konumda duruyor. ORM'lerin getirdiği soyutlama katmanı sizin için fazlaysa ama ham SQL string'leri yazarken tip güvenliğinden vazgeçmek istemiyorsanız, Kysely tam size göre bir araç.
Özellikle şu senaryolarda Kysely parlıyor:
- Performans kritik uygulamalar: Oluşturulan SQL üzerinde tam kontrolünüz var
- Karmaşık sorgular: JOIN'ler, subquery'ler, window function'lar sorunsuz çalışıyor
- Büyük ekipler: Tip güvenliği sayesinde refactoring güvenli hale geliyor
- Mevcut veritabanları: ORM'in şema convention'larına uymayan mevcut veritabanlarıyla çalışırken
Eğer SQL biliyorsanız ve TypeScript kullanıyorsanız, Kysely'i bir sonraki projenizde denemenizi şiddetle tavsiye ederim. SQL yazmaya devam edeceksiniz — ama artık derleyiciniz arkanızda olacak.