Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #22

Node.js'te Veri Erişimi Seçenekleri: node:sqlite, pg, Query Builder (Kysely) ve ORM (Prisma) — Hangisi Ne Zaman?

Mert Özen 15 Kas 2026 13 dk 7 görüntülenme
Node.js'te Veri Erişimi Seçenekleri: node:sqlite, pg, Query Builder (Kysely) ve ORM (Prisma) — Hangisi Ne Zaman?

Node.js'te veri erişimi dört katmanda seçilir: testler ve araçlar için yerleşik node:sqlite, tam kontrol için pg ile düz SQL, derleme zamanı tip güvenliği için Kysely, şema ve migration için Prisma. Gerçek çıktılarla string gelen numeric yüzünden birleşen fiyatlar, INTEGER kolona yazılan metin, SQL Server'da çalışmayacak limit, migrate diff'in yakaladığı ilişki farkı ve dört kitap için beş sorgu üreten N+1.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS; içindeki SQLite 3.54.1), PostgreSQL 18.7, pg 8.23.1, Kysely 0.29.9, Prisma 7.10.1 ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) katalog tablolarını bu yazıda PostgreSQL'de kuruyoruz. İlk sorgu, klasikler kategorisindeki kitapların rafta bekleyen toplam değerini ve fiyatlarının toplamını hesaplıyor:

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// Raftaki klasiklerin toplam değeri: fiyat * stok, toplanmış
const { rows } = await pool.query("SELECT b.price, b.stock FROM books b JOIN categories c ON c.id = b.category_id WHERE c.slug = $1", ["classics"]);
const total = rows.reduce((sum, row) => sum + row.price * row.stock, 0);
const prices = rows.reduce((sum, row) => sum + row.price, 0);
console.log("stock value:", total);
console.log("sum of prices:", prices);
await pool.end();

İki satır da sayı mı yazar?

stock value: 12007
sum of prices: 0245.00229.90310.00199.90

İlki doğru, ikincisi bir string. pg, PostgreSQL'in numeric tipini hassasiyet kaybolmasın diye string olarak döndürüyor. * string'i sayıya çevirdiği için çarpım tesadüfen doğru çıktı; + ise string'leri birleştirdi. TypeScript de uyarmadı, çünkü rows'un tipi any. 13'te SQL Server'da decimal'ın JavaScript'te number'a dönüşmesinin tuzaklarını görmüştük; burada sorun tersinden geliyor. Bu yazıda Node.js'teki dört veri erişimi yaklaşımını aynı "kategoriye göre kitap listesi" sorgusuyla deniyoruz: gömülü node:sqlite, düz SQL ile pg, Kysely ile tip güvenli bir query builder ve Prisma ile bir ORM (veritabanı tablolarını object'lere eşleyen araç). Hangisinin ne zaman uygun olduğuna, ürettikleri SQL'e ve .NET'teki karşılıklarına bakıyoruz.

node:sqlite: Kurulum Gerektirmeyen Veritabanı

Node 22.5'ten beri Node'un içinde SQLite var; paket kurmana gerek yok. Node 26.13.1 dokümanında modülün seviyesi "Release candidate" (1.2): API'nin değişmesi artık beklenmiyor, ama henüz stable değil. API senkron; .NET'teki Microsoft.Data.Sqlite'a benziyor:

// ? parametreli prepared statement'lar; insert'lerin etrafında tek bir transaction
const insertBook = db.prepare("INSERT INTO books VALUES (?, ?, ?, ?, ?, ?, ?)");
db.exec("BEGIN");
db.prepare("INSERT INTO categories (slug, name) VALUES (?, ?), (?, ?)").run("classics", "Classics", "science-fiction", "Science Fiction");
db.prepare("INSERT INTO authors (name) VALUES (?), (?), (?)").run("Jane Austen", "George Eliot", "Frank Herbert");
insertBook.run("9780141439587", "Emma", 1, 1, 24500, "TRY", 12);
insertBook.run("9780141439518", "Pride and Prejudice", 1, 1, 22990, "TRY", 30);
insertBook.run("9780141439549", "Middlemarch", 2, 1, 31000, "TRY", 7);
insertBook.run("9780441172719", "Dune", 3, 2, 34990, "TRY", 18);
db.exec("COMMIT");

const booksByCategory = db.prepare(`
    SELECT b.isbn, b.title, a.name AS author, b.price_minor AS priceMinor, b.currency
    FROM books b
    JOIN authors a ON a.id = b.author_id
    JOIN categories c ON c.id = b.category_id
    WHERE c.slug = ?
    ORDER BY b.title
    LIMIT ?`);
const rows = booksByCategory.all("classics", 10) as { title: string; author: string; priceMinor: number; currency: string }[];
console.log(rows.map((r) => `${r.title} | ${r.author} | ${r.priceMinor} ${r.currency}`).join("\n"));

// SQLite'ta tipler dinamik: INTEGER tanımlı bir kolon metni sorunsuzca saklar
insertBook.run("9780000000002", "Typo", 1, 1, "245,00", "TRY", 1);
const typo = db.prepare("SELECT price_minor, typeof(price_minor) AS stored FROM books WHERE isbn = ?").get("9780000000002");
console.log(typo);

// STRICT tablolar (SQLite 3.37 ve sonrası) yanlış tipi reddeder
db.exec("CREATE TABLE prices (price_minor INTEGER NOT NULL) STRICT");
try {
    db.prepare("INSERT INTO prices VALUES (?)").run("245,00");
} catch (err) {
    console.log((err as Error).message);
}
db.close();
Emma | Jane Austen | 24500 TRY
Middlemarch | George Eliot | 31000 TRY
Pride and Prejudice | Jane Austen | 22990 TRY
[Object: null prototype] { price_minor: '245,00', stored: 'text' }
cannot store TEXT value in INTEGER column prices.price_minor

prepare bir prepared statement döndürüyor, ? parametreleri SQL'den ayrı gidiyor, transaction'ı BEGIN ve COMMIT ile kendin açıyorsun. İkinci ve üçüncü çıktı SQLite'ın en önemli farkını gösteriyor: kolon tipleri bir öneri. INTEGER kolona "245,00" metni sorunsuz yazıldı; SQLite 3.37'den beri gelen STRICT tablolar ise bunu reddediyor. Yeni bir SQLite şemasında STRICT kullan.

API'nin senkron olması önemli: her sorgu event loop'u durdurur. Bu yüzden node:sqlite'ın yeri yoğun bir API'nin ana veritabanı değil; testlerde gerçek SQL çalıştırmak, CLI araçları (19), bir uygulamanın yerel cache'i ya da tek kullanıcılı araçlar. Büyük bir sorguyu bir API içinde çalıştırman gerekirse 21'deki gibi bir worker'a taşı.

pg: Düz SQL ve Connection Pool

PostgreSQL için en yaygın sürücü pg (node-postgres). 13'teki mssql ile aynı kalıplar burada da geçerli: process başına bir pool, parametreli sorgu ve transaction için pool'dan tek bir client:

// Bir transaction, içindeki her komut için pool'dan alınmış aynı client'ı ister
async function reserveStock(isbn: string, quantity: number): Promise<void> {
    const client = await pool.connect();
    try {
        await client.query("BEGIN");
        const result = await client.query("UPDATE books SET stock = stock - $2 WHERE isbn = $1 AND stock >= $2", [isbn, quantity]);
        if (result.rowCount !== 1) {
            throw new Error(`not enough stock for ${isbn}`);
        }
        await client.query("COMMIT");
    } catch (err) {
        await client.query("ROLLBACK");
        throw err;
    } finally {
        client.release();
    }
}
await reserveStock("9780141439587", 2);
await reserveStock("9780141439471", 1).catch((err: Error) => console.log(err.message));
await pool.end();
Emma | Jane Austen | 245.00 TRY
Frankenstein | Mary Shelley | 199.90 TRY
Middlemarch | George Eliot | 310.00 TRY
Pride and Prejudice | Jane Austen | 229.90 TRY
{ price: 'string', stock: 'string', addedAt: true }
not enough stock for 9780141439471

Parametreler $1, $2 biçiminde ve SQL metninden ayrı gönderiliyor; SQL injection'a karşı korunmanın yolu yine bu. Transaction'daki UPDATE ... WHERE stock >= $2 13'teki stok kontrolüyle aynı: Frankenstein'ın stoğu 0 olduğu için satır güncellenmedi ve işlem geri alındı. Tip eşlemesi Önce Tahmin Et'teki gibi: numeric ve bigint string, timestamptz Date geliyor. Para için en sağlam yol, 13'te yaptığımız gibi değeri SQL'de tam sayı kuruşa çevirmek ((price * 100)::int); pg.types.setTypeParser ile numeric'i number'a çevirmek kolay ama büyük değerlerde hassasiyet kaybettirir. .NET'te bu yaklaşımın karşılığı Npgsql ile elle SQL ya da Dapper (PostgreSQL serisinin 11. yazısı).

Kysely: Tip Güvenli Query Builder

Düz SQL'in eksiği, TypeScript'in sorgu hakkında hiçbir şey bilmemesi. Kysely, SQL'i TypeScript'te parça parça kurmanı sağlayan bir query builder; ORM değil, ürettiği SQL yazdığın koda çok yakın. Tabloları bir interface ile tanıtıyorsun (gerçek projede kysely-codegen bunu veritabanından üretir):

// Sorgu, Kysely instance'ını alan bir fonksiyon; her tablo ve kolon adını tsc kontrol ediyor
function booksByCategory(db: Kysely<Database>, slug: string, limit: number) {
    return db
        .selectFrom("books as b")
        .innerJoin("authors as a", "a.id", "b.author_id")
        .innerJoin("categories as c", "c.id", "b.category_id")
        .select(["b.isbn", "b.title", "a.name as author", "b.price", "b.currency"])
        .where("c.slug", "=", slug)
        .orderBy("b.title")
        .limit(limit);
}

const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }) }) });
const query = booksByCategory(db, "classics", 10);
const compiled = query.compile();
console.log(compiled.sql);
console.log(compiled.parameters);
const rows = await query.execute();
console.log(rows.map((r) => `${r.title} | ${r.author} | ${r.price}`).join("\n"));
await db.destroy();
select "b"."isbn", "b"."title", "a"."name" as "author", "b"."price", "b"."currency" from "books" as "b" inner join "authors" as "a" on "a"."id" = "b"."author_id" inner join "categories" as "c" on "c"."id" = "b"."category_id" where "c"."slug" = $1 order by "b"."title" limit $2
[ 'classics', 10 ]
Emma | Jane Austen | 245.00
Frankenstein | Mary Shelley | 199.90
Middlemarch | George Eliot | 310.00
Pride and Prejudice | Jane Austen | 229.90
select "b"."isbn", "b"."title", "a"."name" as "author", "b"."price", "b"."currency" from "books" as "b" inner join "authors" as "a" on "a"."id" = "b"."author_id" inner join "categories" as "c" on "c"."id" = "b"."category_id" where "c"."slug" = @1 order by "b"."title" limit @2
select top(10) "b"."isbn", "b"."title" from "books" as "b" inner join "categories" as "c" on "c"."id" = "b"."category_id" where "c"."slug" = @1 order by "b"."title"

İlk satır Kysely'nin PostgreSQL için ürettiği SQL: elle yazdığımızla aynı sorgu, parametreler yine ayrı. Kazanç derleme zamanında. Kolon adını yanlış yazınca ya da category_id'yi bir string'le karşılaştırınca tsc hata veriyor (bu dosyayı yalnız denemek için oluşturup sildim):

export function typo(db: Kysely<Database>) {
    return db.selectFrom("books").select(["isbn", "titel"]).where("category_id", "=", "classics");
}
typo-check.ts(5,42): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Argument of type 'string[]' is not assignable to parameter of type 'SelectExpression<Database, "books">'.
typo-check.ts(5,87): error TS2345: Argument of type '"classics"' is not assignable to parameter of type 'OperandValueExpressionOrList<Database, "books", "category_id">'.

Kysely'nin SQL Server için hazır bir dialect'i de var (MssqlDialect, tedious ve tarn ile). Bağlantı kurmadan, aynı fonksiyonun SQL Server için ne üreteceğine baktık:

// Aynı fonksiyon, bağlantı kurmadan SQL Server için derleniyor: tedious'a ne giderdi?
const sqlServer = new Kysely<Database>({
    dialect: {
        createAdapter: () => new MssqlAdapter(),
        createDriver: () => new DummyDriver(),
        createIntrospector: (db) => new MssqlIntrospector(db),
        createQueryCompiler: () => new MssqlQueryCompiler(),
    },
});
console.log(booksByCategory(sqlServer, "classics", 10).compile().sql);

// SQL Server'da LIMIT yok: orada sorgu top() (ya da offset/fetch) ister
const tsql = sqlServer
    .selectFrom("books as b")
    .innerJoin("categories as c", "c.id", "b.category_id")
    .top(10)
    .select(["b.isbn", "b.title"])
    .where("c.slug", "=", "classics")
    .orderBy("b.title");
console.log(tsql.compile().sql);

Kysely çıktısının son iki satırı bu kodun sonucu. İlki limit @2 ile bitiyor, ama SQL Server'da LIMIT yok; bu sorgu SQL Server'da çalışmaz. Query builder dialect'ler arasında syntax'ı çeviriyor, ama her özelliği her veritabanına taşımıyor; SQL Server'da top() ya da offset/fetch kullanman gerekiyor. "Bir kez yaz, her veritabanında çalıştır" beklentisiyle seçme. Bu SQL Server sorgularını bu yazıda gerçek bir SQL Server'da çalıştırmadık; yalnız üretilen SQL'i gösterdik. .NET'te tam bir karşılığı yok; Dapper ile LINQ (C#'ın dile gömülü sorgu syntax'ı) arasında bir yerde duruyor.

Prisma: Şema, Migration ve Üretilen Client

Prisma, Entity Framework Core'a (EF Core) en yakın seçenek: modeller bir şema dosyasında tanımlanıyor, prisma generate bu şemadan tipli bir client üretiyor, prisma migrate da şema değişikliklerini SQL migration'larına çeviriyor. Prisma 7 ile iki değişiklik var: client node_modules yerine senin belirlediğin bir klasöre TypeScript olarak üretiliyor ve veritabanına bir driver adapter üzerinden bağlanıyor (burada pg ile @prisma/adapter-pg; SQL Server için @prisma/adapter-mssql var). Tablolarımızı SQL ile kurduğumuz için şemayı elle yazdık ve önce veritabanıyla karşılaştırdık:

node --env-file=.env node_modules/prisma/build/index.js migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
-- DropForeignKey
ALTER TABLE "books" DROP CONSTRAINT "books_author_id_fkey";

-- DropForeignKey
ALTER TABLE "books" DROP CONSTRAINT "books_category_id_fkey";

-- AddForeignKey
ALTER TABLE "books" ADD CONSTRAINT "books_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "authors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "books" ADD CONSTRAINT "books_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

İyi ki baktık: Prisma'nın varsayılan ilişki ayarları (ON UPDATE CASCADE) bizim SQL'imizdekinden farklı ve migration bunları değiştirmek istiyordu. İlişkilere onDelete: NoAction, onUpdate: NoAction ekleyince fark kayboldu ve aynı komut boş bir migration verdi. EF Core'da da konvansiyonların ürettiği farklar ancak migration'a bakınca görünür; aynı alışkanlık burada da işe yarıyor. Sorgu:

const books = await prisma.book.findMany({
    where: { category: { slug: "classics" } },
    select: { isbn: true, title: true, price: true, currency: true, author: { select: { name: true } } },
    orderBy: { title: "asc" },
    take: 10,
});
console.log(books.map((b) => `${b.title} | ${b.author.name} | ${b.price.toFixed(2)}`).join("\n"));
// numeric(10,2) Prisma.Decimal olur: number'ın aksine kayıpsız
console.log(`price is Prisma.Decimal: ${books[0].price instanceof Prisma.Decimal}, queries: ${queries}`);

// N+1 tuzağı: yazar döngüde yükleniyor, kitap başına bir sorgu
queries = 0;
const plain = await prisma.book.findMany({ where: { category: { slug: "classics" } }, orderBy: { title: "asc" } });
for (const book of plain) {
    const author = await prisma.author.findUnique({ where: { id: book.authorId } });
    void author;
}
console.log(`N+1 version: ${plain.length} books, queries: ${queries}`);
Emma | Jane Austen | 245.00
Frankenstein | Mary Shelley | 199.90
Middlemarch | George Eliot | 310.00
Pride and Prejudice | Jane Austen | 229.90
price is Prisma.Decimal: true, queries: 2
N+1 version: 4 books, queries: 5

Sorgunun ürettiği SQL'i log ayarıyla gördük; ilk sorgu iki SQL çalıştırdı:

  SQL: SELECT "public"."books"."isbn", "public"."books"."title", "public"."books"."price", "public"."books"."currency", "public"."books"."author_id" FROM "public"."books" LEFT JOIN "public"."categories" AS "j0" ON ("j0"."id") = ("public"."books"."category_id") WHERE ("j0"."slug" = $1 AND ("j0"."id" IS NOT NULL)) ORDER BY "public"."books"."title" ASC LIMIT $2 OFFSET $3
  SQL: SELECT "public"."authors"."id", "public"."authors"."name" FROM "public"."authors" WHERE "public"."authors"."id" IN ($1,$2,$3,$4) OFFSET $5

Prisma ilişkiyi tek bir JOIN ile değil, kitapları ve yazarları iki ayrı sorguyla (IN (...)) çekip bellekte birleştirdi. Bu, EF Core'daki split query'ye benziyor ve kitap sayısı ne olursa olsun iki sorgu. İkinci sürümde ise yazarı döngünün içinde tek tek okuduk: dört kitap için beş sorgu. Kitap sayısı arttıkça sorgu sayısı da artıyor; bu N+1 problemi. ORM'de ilişkiyi her zaman sorgunun içinde iste (include ya da select) ve geliştirme sırasında sorgu log'unu açık tut. Tip tarafında Prisma, numeric(10,2)'yi kayıpsız bir Prisma.Decimal'a çeviriyor; Önce Tahmin Et'teki hata burada olmuyor.

Hangisi Ne Zaman?

Yaklaşım.NET karşılığıTip güvenliğiSQL üzerinde kontrolNe zaman
node:sqliteMicrosoft.Data.SqliteYok (sonuç unknown)TamTestler, CLI araçları, yerel cache
pg / mssql + SQLSqlClient / Npgsql, DapperYok, elle tipTamAz sayıda, performansı kritik sorgu; raporlar
KyselyDapper ile LINQ arasıDerleme zamanında, tablo ve kolon bazındaYüksek; SQL'e yakınSQL bilen ekip, tip güvenliği isteyen API'ler
PrismaEF CoreÜretilen client ile tamDaha az; SQL'i Prisma üretirŞemanın sık değiştiği, migration'ın önemli olduğu projeler

Bu seride Bookstore API'sinde 13'ten beri mssql ve elle SQL kullanıyoruz; Dapper serisine paralel bir tercih ve az sayıda, iyi bilinen sorgusu olan bir API için yeterli. Drizzle da sık anılan bir seçenek; SQL Server desteği bugün yalnız 1.0 RC (release candidate, yani yayın adayı) sürümünde var (npm'deki latest 0.45.4'ün peer dependency'lerinde mssql yok, 1.0.0-rc.5'te var); Drizzle'ı bu yazıda denemedim.

Üretimde Dikkat

Paket sürümlerini sabitle. Bugün npm'de Prisma'nın iki paketinin latest etiketi farklı major'lara işaret ediyor:

npm view prisma dist-tags.latest
npm view @prisma/client dist-tags.latest
8.0.0-rc.33
7.10.1

npm install prisma bugün bir release candidate kuruyor, client ise 7.10.1'de kalıyor. Lab'ın package.json'ında Prisma paketleri bu yüzden tam sürümle (7.10.1) yazılı ve lock dosyası commit'li (04). Aynı dikkat node:sqlite için de geçerli: RC seviyesindeki bir API'yi production'daki kritik bir yola koymadan önce, stable olana kadar yükseltmelerde değişebileceğini kabul et. Prisma'nın kurulumu ayrıca bir install script'i ile şema motoru binary'sini indiriyor; npm 11 bunu allowScripts ile onaylanmamış script olarak uyarıyor (04'teki lifecycle script konusu).

Pool boyutunu veritabanının bağlantı sınırına göre ayarla: replika sayısı çarpı pool boyutu, PostgreSQL'in max_connections'ını aşmasın. Shutdown'da pool'u en son kapat (14).

Sık Düşülen Tuzaklar

ORM'de ilişkiyi döngüde yüklemek. N+1 sorgu üretir; ilişkiyi sorgunun içinde iste.

Veritabanı tiplerini JavaScript tiplerine körü körüne çevirmek. numeric string, bigint string gelir; decimal'ı number'a çevirmek hassasiyet kaybettirir (13).

Query builder'ı veritabanından bağımsız sanmak. limit SQL Server'da çalışmaz.

SQLite'ın kolon tiplerine güvenmek. STRICT olmayan tablo, yanlış tipi sessizce saklar.

RC ya da latest etiketli bir sürümü kontrol etmeden kurmak. Sürümü sabitle, lock dosyasını commit'le.

Küçük Bir Deneme

★☆☆ labs/22/guess.ts'i, fiyatları SQL'de kuruşa çevirerek ((b.price * 100)::int) düzelt ve toplamı tam sayı olarak hesapla.

★★☆ 1-sqlite.ts'teki tabloları STRICT yap, aynı sorguyu bir node:test testine çevir ve her test için yeni bir :memory: veritabanı kullan.

★★★ Bookstore API'sinin BookRepository interface'i için Kysely ile bir PostgreSQL implementasyonu yaz: sayfalama ve sıralama 12'deki sözleşmeyle aynı olsun, 16'daki API testleri bu repository ile de geçsin.

Seri burada bitiyor. V8 (JavaScript engine'i) ve libuv'dan başlayıp module sistemi, npm, build'siz TypeScript, event loop, stream'ler, Express 5, SQL Server, production hazırlığı, güvenlik ve testle Bookstore'un Node.js tarafını kurduk; son beş yazıda da her backend'in günlük ihtiyacı olan HTTP client'ı, CLI araçlarını, kriptografiyi, worker thread'leri ve veri erişimi seçeneklerini ekledik. Buradan sonraki doğal adım, bu API'yi bir container image'ına koymak ve ölçmek: Docker serisi ve Observability serisi tam bu noktadan devam ediyor.