İçeriğe geç
Sedat Demir
Geri dön

Kysely ile Type-Safe SQL Query Builder: Tam Rehber

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/pg

Veritabanı Ş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:

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 db

Bu 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ır

Migration 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ı

Dezavantajları

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/mydb

Bu 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:

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.


Share this post on:

Sonraki Yazı
React Server Components Olgunlaşması: "use client" ve "use server" Paradigmasını Derinlemesine Anlamak