İçeriğe geç
XDD.baby Yazılım Öğrenme
Sözlük Hata rehberi 0/56

Blok 5 · Ders 37

Node.js'ten PostgreSQL'e bağlanmak

API'ni veritabanına bağlıyorsun: bağlantı dizesi, parametreli sorgu, SQL injection'dan korunma ve şema dosyası.

Bu dersin sonunda

  • pg paketiyle bir bağlantı havuzu (Pool) kurup Node.js'ten PostgreSQL'e sorgu gönderebilirsin
  • Bağlantı dizesini .env dosyasında tutabilir, parçalarını ve sslmode ayarını açıklayabilirsin
  • Kullanıcıdan gelen her değeri $1, $2 parametreleriyle gönderip SQL injection'ı engelleyebilirsin
  • Sorgu sonucundaki satırları Express'ten doğru tiplerle JSON olarak döndürebilirsin
  • Postgres hata kodlarını (23505, 23503, 22P02) 409 ve 400 gibi HTTP durum kodlarına çevirebilirsin
  • Tablo değişikliklerini sıralı göç dosyalarıyla uygulayabilir, ORM'lerin ne zaman işe yaradığını anlatabilirsin
Bu sayfada
  1. Üç program, tek istek
  2. Proje klasörü ve pg paketi
  3. Veritabanını hazırla: schema.sql
  4. Bağlantı dizesi ve .env
  5. İlk sorgu: Pool ve query
  6. Satırlar JSON olur
  7. Havuz: bağlantıları paylaşmak
  8. SQL injection: kullanıcının yazdığı metin
  9. Parametreli sorgu: $1, $2
  10. Yazmak ve hata kodlarını çevirmek
  11. Şema dosyası ve göç (migration)
  12. Neon'un sunucusuz sürücüsü
  13. ORM'leri tanımak
  14. Görevler
  15. Kendini sına
  16. Kayıt noktası

Ders 27 ve 28'de yazdığın API'lerin verisi bir dizide, Ders 29'da bir JSON dosyasında duruyordu. Ders 33–36'da ise veriyi PostgreSQL tablolarına koydun ve Neon'un SQL Editor'ünde sorguladın. Bu iki dünya şimdiye kadar birbirini hiç görmedi.

Bu derste onları birleştiriyorsun: Node.js'ten veritabanına bağlanacak, her istekte SQL çalıştırıp satırları JSON olarak döndürecek, kullanıcının yazdığı değerleri sorguya güvenle koyacak ve veritabanının hatalarını anlamlı HTTP yanıtlarına çevireceksin. Sonunda tablolarını sıralı dosyalarla değiştirmeyi öğrenecek ve ORM denen araçları tanıyacaksın. Takım API'ni Postgres'e taşımak Ders 38'in projesi; bu derste aynı mekanizmayı küçük ve temiz bir API üzerinde, parça parça kuruyorsun.

Üç program, tek istek

Bir isteğe artık üç program katılıyor. İstemci (tarayıcı ya da curl.exe) Express'e bir HTTP isteği atar. Express isteğe bakar, hangi SQL'in çalışacağına karar verir ve sorguyu veritabanına gönderir. PostgreSQL satırları geri yollar; Express onları JSON'a çevirip istemciye döndürür.

İstemci Express + pg PostgreSQL 1. GET /oyuncular 2. SELECT … FROM oyuncular 3. 5 satır (rows) 4. [{"id":1,…}, …]
Şekil 37.1 Bir isteğin yolculuğu. İstemci yalnız Express'le konuşur; veritabanıyla konuşan Express'tir.

İstemci veritabanını hiç görmez. Veritabanının adresi ve şifresi yalnız sunucunda durur; dışarıya açık tek kapı senin API'n. Kimin neyi okuyup yazabileceğine de böylece sen karar verirsin.

Express ile PostgreSQL arasındaki konuşmayı pg paketi yapar. Postgres HTTP konuşmaz; kendi protokolünü, açık tutulan bir bağlantı üzerinden konuşur. pg bu dili bilen bir veritabanı sürücüsüdür: sen pool.query("SELECT ...") yazarsın, o sorguyu Postgres'in anlayacağı mesajlara çevirip gönderir, gelen yanıtı JavaScript objelerine dönüştürür.

Proje klasörü ve pg paketi

Bu derste yeni bir proje klasöründe çalışacaksın. Terminalde cd C:\kod ile kod klasörüne geç, mkdir pg-api ile klasörü oluştur ve cd pg-api ile içine gir. Sonra package.json oluştur ve iki paket kur: Express ve pg.

Terminal — PowerShell
PS C:\kod\pg-api> npm init -yWrote to C:\kod\pg-api\package.json: {  "name": "pg-api",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "test": "echo \"Error: no test specified\" && exit 1"  },  "keywords": [],  "author": "",  "license": "ISC",  "type": "commonjs"} PS C:\kod\pg-api> npm install express pg added 79 packages, and audited 80 packages in 2s 28 packages are looking for funding  run `npm fund` for details found 0 vulnerabilities

Klasörü VS Code'da aç (File → Open Folder). package.json'da "type" satırını "module" yap; böylece import yazabilir ve await'i dosyanın en üst düzeyinde kullanabilirsin (Ders 14, Ders 15). Sonra scripts kısmını iki betikle değiştir:

package.json
{  "name": "pg-api",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "dev": "node --watch --env-file=.env app.js",    "start": "node --env-file=.env app.js"  },  "keywords": [],  "author": "",  "license": "ISC",  "type": "module",  "dependencies": {    "express": "^5.3.0",    "pg": "^8.23.1"  }}
  • --env-file=.env: Node, programı başlatmadan önce .env dosyasındaki değişkenleri process.env'e yükler (Ders 29).
  • --watch: dosyayı her kaydettiğinde programı yeniden başlatır (Ders 27).
  • dev geliştirirken, start canlı ortamda kullanılır.

Bu ders Express 5.3.0 ve pg 8.23.1 ile denendi. Senin sürümlerin biraz daha yeni olabilir; npm view pg version en güncelini söyler.

Birazdan veritabanı şifren .env dosyasında duracak. O dosya Git'e asla girmemeli, bu yüzden işe .gitignore ile başla:

.gitignore
node_modules.env

Veritabanını hazırla: schema.sql

Ders 36'daki tabloların Neon'da duruyor ve görevlerde onları değiştirmiş olabilirsin. Bu derste aynı oyuncular ve maclar tablolarının sade bir kopyasını, derse ayrılmış temiz bir veritabanında kuracaksın; böylece senin gördüğün sonuçlar dersteki çıktılarla birebir aynı olur. Bir Neon projesinde birden çok veritabanı yan yana durabilir.

  1. Neon'da projeni aç, SQL Editor'de CREATE DATABASE pgapi; yaz ve Run'a bas.
  2. SQL Editor'ün üstündeki veritabanı listesinden pgapi'yi seç. Arayüz değişmiş olabilir; listeyi göremezsen sayfayı yenile.
  3. VS Code'da schema.sql adında bir dosya aç, aşağıdaki iki tabloyu yaz ve kaydet. İçeriği kopyalayıp SQL Editor'de çalıştır.
  4. Ardından örnek veriyi aynı yoldan ekle; onu da veri.sql adıyla projene kaydet.
schema.sql
CREATE TABLE oyuncular (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    ad text NOT NULL UNIQUE,    bolge text NOT NULL); CREATE TABLE maclar (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    oyuncu_id integer NOT NULL REFERENCES oyuncular (id),    sampiyon text NOT NULL,    sonuc text NOT NULL CHECK (sonuc IN ('zafer', 'yenilgi')),    kill integer NOT NULL CHECK (kill >= 0),    tarih timestamptz NOT NULL DEFAULT now());

Tablolar Ders 36'dakilerin kısaltılmış hâli; olum ve asist yok. İki küçük fark var: kill eksi olamaz (CHECK, Ders 35) ve tarih sütunu maçın kaydedildiği anı tutar. DEFAULT now() sayesinde tarihi sen yazmazsan Postgres o anı koyar.

veri.sql
INSERT INTO oyuncular (ad, bolge) VALUES    ('Deniz', 'TR'),    ('Ece', 'TR'),    ('Mert', 'EUW'),    ('Selin', 'TR'),    ('Kaan', 'EUW'); INSERT INTO maclar (oyuncu_id, sampiyon, sonuc, kill) VALUES    (1, 'Ahri', 'zafer', 8),    (1, 'Lux', 'yenilgi', 3),    (2, 'Jinx', 'zafer', 12),    (1, 'Ahri', 'zafer', 10),    (3, 'Garen', 'yenilgi', 2),    (2, 'Lux', 'zafer', 6),    (4, 'Teemo', 'yenilgi', 4);

Kaan'ın hiç maçı yok; istatistik yazarken bu boşluk işine yarayacak. İki dosya artık projenin parçası. schema.sql veritabanının şemasını, yani iskeletini anlatıyor: bir gün yeni bir veritabanı kurman gerekirse (takım arkadaşının bilgisayarı, canlı ortam) aynı dosyayı çalıştırırsın. Tablolar sonradan değişince ne yapacağını dersin sonunda, göç bölümünde göreceksin.

Bağlantı dizesi ve .env

Node'un Neon'a bağlanması için tek satırlık bir bilgi gerekiyor: bağlantı dizesi (connection string). Neon'da proje sayfasındaki Connect düğmesine bas ve açılan pencerede veritabanı olarak pgapi'yi seç. Şuna benzeyen bir dize görürsün (arayüz değişmiş olabilir; değerler Neon'un belgesindeki örnek):

Metin
postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/pgapi?sslmode=require&channel_binding=require
postgresql://kullanıcı:şifre@sunucu/veritabanı?ayarlar postgresql:// protokol: Postgres'in dili neondb_owner kullanıcı (Neon'da rol) AbC123dEf şifre: yalnız .env'de durur ep-…-pooler.us-east-2.aws.neon.tech sunucunun adresi pgapi veritabanının adı sslmode=verify-full ayar: şifreli bağlantı
Şekil 37.2 Bağlantı dizesinin parçaları. Üstte genel kalıp, altta her parçanın Neon'daki karşılığı.

Dizeyi koda yazmak yerine .env dosyasına koy (Ders 29). Proje klasöründe .env adında bir dosya aç ve tek satır yaz:

.env
DATABASE_URL=postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/pgapi?sslmode=verify-full&channel_binding=require

Buraya kendi dizeni yapıştır. Tek bir değişiklik yap: sslmode=require'ı sslmode=verify-full yap. Nedenini hemen aşağıda anlatıyorum.

sslmode: şifreli bağlantı

Bilgisayarınla Neon arasındaki yol internet; araya giren biri trafiği okuyabilir. SSL (bugünkü adıyla TLS) bağlantıyı şifreler ve karşıdaki sunucunun gerçekten Neon olduğunu sertifikasıyla kanıtlar. Tarayıcıdaki https'in (Ders 18) veritabanı tarafındaki karşılığı budur. Neon şifrelenmemiş bağlantıyı hiç kabul etmez.

sslmode bu işin ne kadar sıkı yapılacağını söyler: require "şifrele" der, verify-full ise ayrıca sunucunun sertifikasını ve adını da denetler. Neon her zaman verify-full kullanmanı öneriyor.

Neon'un verdiği dizede sslmode=require yazar. Onu değiştirmezsen pg 8.23, birazdan yazacağın her programın açılışında şu uzun uyarıyı basar:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.js(node:29044) Warning: SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'.In the next major version (pg-connection-string v3.0.0 and pg v9.0.0), these modes will adopt standard libpq semantics, which have weaker security guarantees. To prepare for this change:- If you want the current behavior, explicitly use 'sslmode=verify-full'- If you want libpq compatibility now, use 'uselibpqcompat=true&sslmode=require' See https://www.postgresql.org/docs/current/libpq-ssl.html for libpq SSL mode definitions.(Use `node --trace-warnings ...` to show where the warning was created)...

Uyarı şunu söylüyor: pg şu an require'ı en sıkı mod olan verify-full gibi uyguluyor, ama 9. sürümde require daha gevşek davranacak. Bugünkü güvenli davranışı korumak için açıkça verify-full yaz.

Neon'un sertifikası herkesin tanıdığı Let's Encrypt kökünden geldiği için ek bir dosyaya gerek yok. Dizedeki channel_binding=require'ı ise pg kullanmaz; durması zarar vermez.

İlk sorgu: Pool ve query

Veritabanıyla konuşan kodu tek bir dosyada topla. db.js adında bir dosya aç:

db.js
import { Pool } from "pg" export const pool = new Pool({    connectionString: process.env.DATABASE_URL})
  • import { Pool } from "pg": pg paketinden Pool sınıfını al (Ders 14, Ders 16).
  • new Pool({ ... }): bir bağlantı havuzu kur. Ne olduğunu birazdan göreceksin; şimdilik "veritabanına açılan kapı" diye düşün.
  • connectionString: process.env.DATABASE_URL: kapının adresi .env'den gelir. Kodda şifre yok.
  • export const pool: programın her dosyası bu havuzu içe aktarır. Node bir modülü yalnız ilk içe aktarılışında çalıştırır; db.js'i kaç dosya içe aktarırsa aktarsın havuz tektir.

Şimdi deneme.js adında bir dosya aç ve ilk sorgunu yaz:

deneme.js — pg-api — Visual Studio Code
deneme.js
import { pool } from "./db.js" const sonuc = await pool.query(    "SELECT 'Merhaba Postgres' AS mesaj, 2 + 3 AS toplam")console.log(sonuc.rows)await pool.end()
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
PS C:\kod\pg-api> node --env-file=.env deneme.js[ { mesaj: 'Merhaba Postgres', toplam: 5 } ]
Ln 1, Col 1Spaces: 4UTF-8JavaScript

Sorgu Neon'da çalıştı, sonuç Node'a bir obje dizisi olarak geldi. --env-file=.env'i unutma: o olmadan DATABASE_URL boş kalır.

  • pool.query(...) sorguyu gönderir ve bir Promise döndürür; await sonucu bekler (Ders 15).
  • sonuc.rows: gelen satırlar, her biri bir obje. Sütun adları objenin anahtarları oldu: AS mesaj yazdığın için anahtar mesaj.
  • await pool.end(): havuzu kapatır. Bu satırı silip denediğimizde program sonucu hemen yazdı ama ancak 10 saniye kadar sonra kapandı: havuz boştaki bağlantıyı 10 saniye açık tutar. Tek seferlik betiklerde pool.end() yaz. Sunucuda (app.js) yazmayacaksın; sunucu kapanmaz, havuz da açık kalmalı.

Sonuç objesi

Şimdi gerçek tabloyu sorgula. deneme.js'deki sorguyu şununla değiştir:

deneme.js
import { pool } from "./db.js" const sonuc = await pool.query("SELECT id, ad, bolge FROM oyuncular WHERE bolge = 'TR'")console.log(sonuc.rows)console.log(sonuc.rowCount)console.log(sonuc.command)console.log(sonuc.fields.map((alan) => alan.name))await pool.end()
Çıktı
[  { id: 1, ad: 'Deniz', bolge: 'TR' },  { id: 2, ad: 'Ece', bolge: 'TR' },  { id: 4, ad: 'Selin', bolge: 'TR' }]3SELECT[ 'id', 'ad', 'bolge' ]
AlanNe tutar
rowsSatırlar: her satır bir obje, sütun adları anahtar
rowCountKaç satır geldi; UPDATE ve DELETE'te kaç satır değişti
commandÇalışan komutun adı: SELECT, INSERT, DELETE…
fieldsSütunların bilgisi (ad, tip)

Çoğu zaman yalnız rows lazım olur; o yüzden sık sık parçalayarak yazacaksın: const { rows } = await pool.query(...) (Ders 12).

Sık hataTypeError: Cannot read properties of undefined (reading 'length')

Ne zaman çıkar? pool.query'nin önüne await yazmayı unutunca. Elindeki şey sonuç değil, henüz bitmemiş bir Promise; onun rows diye bir alanı yok (Ders 15).

deneme.js
import { pool } from "./db.js" const sonuc = pool.query("SELECT id, ad FROM oyuncular")console.log(sonuc.rows.length)
Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsfile:///C:/kod/pg-api/deneme.js:4console.log(sonuc.rows.length)                       ^ TypeError: Cannot read properties of undefined (reading 'length')    at file:///C:/kod/pg-api/deneme.js:4:24    ...

Nasıl düzelir? const sonuc = await pool.query(...). Express'te route fonksiyonunun başında async olduğunu da denetle: bir fonksiyonun içindeki await ancak o fonksiyon async ise çalışır. deneme.js'te en üst düzeyde yazabilmenin sebebi dosyanın bir ES modülü olması (Ders 15).

Bağlanamazsan

İlk bağlantı her zaman ilk denemede kurulmaz. Mesaj ne olursa olsun önce en alttaki at async file:///...deneme.js:3:15 satırına bak: hatanın senin kodunda hangi satırdan çıktığını o söyler. En üstteki pg-pool\index.js:45 satırı paketin içi; orada düzeltilecek bir şey yok.

Sık hataAggregateError [ECONNREFUSED]

Ne zaman çıkar? Çoğu zaman --env-file=.env'i unutunca. DATABASE_URL boş kalır, pg da varsayılan adrese, kendi bilgisayarındaki 5432 numaralı porta (Postgres'in varsayılan portu) bağlanmaya çalışır. Orada çalışan bir Postgres yoksa bağlantı reddedilir (refused).

Terminal — PowerShell
PS C:\kod\pg-api> node deneme.jsC:\kod\pg-api\node_modules\pg-pool\index.js:45    Error.captureStackTrace(err)          ^ AggregateError [ECONNREFUSED]:     at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/deneme.js:3:15 {  code: 'ECONNREFUSED',  [errors]: [    Error: connect ECONNREFUSED ::1:5432    ...    Error: connect ECONNREFUSED 127.0.0.1:5432    ...  ]} Node.js v24.20.0

Hata iki tane, çünkü Node localhost'u iki adresle denedi: ::1 (yeni tip, IPv6) ve 127.0.0.1 (eski tip, IPv4). İkisi de aynı yer: senin bilgisayarın. Bilgisayarında kendi kurduğun bir Postgres çalışıyorsa mesaj bu kez SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string olur: bağlantı kuruldu, ama şifre hiç verilmedi.

Nasıl düzelir? node --env-file=.env deneme.js ya da npm run dev ile çalıştır. Emin değilsen console.log(process.env.DATABASE_URL) yaz: undefined görüyorsan dosya okunmamıştır. Dosyanın adı tam olarak .env mi (.env.txt değil, Ders 0), proje klasöründe mi, denetle. Bu hatayı daha anlaşılır bir mesaja çevirmeyi birazdan, sağlam db.js bölümünde yapacaksın.

Sık hatapassword authentication failed for user "neondb_owner"

Ne zaman çıkar? Sunucuya ulaştın ama şifre yanlış. Dizeyi eksik kopyaladın, yıldızlı hâlini aldın ya da şifreyi sıfırladıktan sonra .env'i güncellemedin.

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsC:\kod\pg-api\node_modules\pg-pool\index.js:45    Error.captureStackTrace(err)          ^ error: password authentication failed for user "neondb_owner"    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/deneme.js:3:15 {  length: 108,  severity: 'FATAL',  code: '28P01',  ...}

Bu çıktı kendi bilgisayarımızdaki bir PostgreSQL'den alındı. Neon aynı durumda kullanıcı adını tek tırnakla yazar: password authentication failed for user 'neondb_owner'.

Nasıl düzelir? Connect penceresinden dizeyi kopyalama düğmesiyle yeniden al ve .env'deki satırın tamamını değiştir. Olmazsa Neon'da şifreyi sıfırla (Roles → Reset password) ve yeni dizeyi yaz. npm run dev çalışıyorsa .env'i kaydetmen yeter: --watch, --env-file ile verilen dosyayı da izler ve programı yeni değerle yeniden başlatır. npm start ile çalıştırıyorsan durdurup yeniden başlat.

Sık hatagetaddrinfo ENOTFOUND

Ne zaman çıkar? Sunucu adı yanlış yazılınca ya da internet bağlantın yokken. getaddrinfo, alan adını IP adresine çeviren işlemdir (Ders 18'deki DNS); ENOTFOUND "böyle bir ad bulunamadı" demek. Buradaki dizede neon.tech yerine neon.tch yazılmış:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsC:\kod\pg-api\node_modules\pg-pool\index.js:45    Error.captureStackTrace(err)          ^ Error: getaddrinfo ENOTFOUND ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tch    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/deneme.js:3:15 {  errno: -3008,  code: 'ENOTFOUND',  syscall: 'getaddrinfo',  hostname: 'ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tch'}

Nasıl düzelir? Mesajın sonundaki hostname satırı, aranan adı aynen yazıyor; Connect penceresindekiyle karşılaştır. Ad doğruysa tarayıcıda bir siteyi açarak internet bağlantını denetle.

Sık hataThe server does not support SSL connections

Ne zaman çıkar? Kendi bilgisayarında (ya da Docker'da) çalışan, SSL'i açık olmayan bir Postgres'e sslmode=require ya da verify-full içeren bir dizeyle bağlanınca. Bir öğreticiyi izlerken yerel bir Postgres kurduysan karşına çıkabilir.

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsC:\kod\pg-api\node_modules\pg-pool\index.js:45    Error.captureStackTrace(err)          ^ Error: The server does not support SSL connections    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/deneme.js:3:15 Node.js v24.20.0

Nasıl düzelir? Yerel bir Postgres'te trafik bilgisayarından dışarı çıkmaz; dizenin sonundaki ?sslmode=... kısmını sil. Neon'a bağlanırken ise sslmode=verify-full hep kalmalı.

Satırlar JSON olur

Şimdi sorguyu bir uç noktaya taşı. Proje klasöründe app.js'i aç:

app.js
import express from "express"import { pool } from "./db.js" const app = express()const port = process.env.PORT || 3000app.use(express.json()) app.get("/oyuncular", async (req, res) => {    const { rows } = await pool.query("SELECT id, ad, bolge FROM oyuncular ORDER BY id")    res.json(rows)}) app.listen(port, () => {    console.log(`API hazır: http://localhost:${port}`)})

Express kısmı Ders 28'deki gibi; yeni olan tek satır pool.query. process.env.PORT || 3000 ise "barındırıcı bir port verdiyse onu, vermediyse 3000'i kullan" demek. Sunucuyu başlat, sonra ikinci bir terminal açıp (Ders 27) iste:

Terminal — PowerShell
PS C:\kod\pg-api> npm run dev > pg-api@1.0.0 dev> node --watch --env-file=.env app.js API hazır: http://localhost:3000
İkinci terminal
PS C:\kod\pg-api> curl.exe http://localhost:3000/oyuncular[{"id":1,"ad":"Deniz","bolge":"TR"},{"id":2,"ad":"Ece","bolge":"TR"},{"id":3,"ad":"Mert","bolge":"EUW"},{"id":4,"ad":"Selin","bolge":"TR"},{"id":5,"ad":"Kaan","bolge":"EUW"}]

Tablo artık bir uç noktada. res.json(rows) diziyi JSON.stringify ile metne çevirip Content-Type: application/json; charset=utf-8 başlığıyla gönderir (Ders 28). Satırlar zaten düz objeler olduğu için başka bir şey yapman gerekmedi. Ama her sütun bu kadar uysal değil.

Tipler: Postgres'ten JavaScript'e

deneme.js'de maçları özetleyen bir sorgu dene:

deneme.js
import { pool } from "./db.js" const sonuc = await pool.query("SELECT count(*) AS mac, avg(kill) AS ortalama, max(tarih) AS son FROM maclar")console.log(sonuc.rows)console.log(JSON.stringify(sonuc.rows))await pool.end()
Çıktıörnek, sende farklı çıkabilir
[  {    mac: '7',    ortalama: '6.4285714285714286',    son: 2026-10-11T17:02:39.352Z  }][{"mac":"7","ortalama":"6.4285714285714286","son":"2026-10-11T17:02:39.352Z"}]

mac ve ortalama tek tırnak içinde: sayı değil, metin. son ise tırnaksız ama bir sayı da değil; bir Date objesi. JSON.stringify onu metne çevirdi. Saatin sonundaki Z saatin UTC olduğunu söyler; Türkiye saati üç saat ileridedir. pg her Postgres tipini şöyle karşılar:

Postgres tipiNode'a gelenJSON'da
integersayı: 33
textmetin: 'Deniz'"Deniz"
booleantrue / falsetrue / false
timestamptzDate objesi"2026-10-11T17:02:39.352Z"
bigint (count sonucu)metin: '7'"7"
numeric (avg, round sonucu)metin: '6.4285714285714286'"6.4285714285714286"
NULLnullnull

Neden metin? JavaScript'in sayıları 9.007.199.254.740.991'den büyük tam sayıları kesin tutamaz; bigint ise 9.223.372.036.854.775.807'ye kadar gider. numeric de istediğin kadar basamak tutabilir; JavaScript sayısına çevirmek basamak kaybettirebilir (Ders 9'daki ondalık tuzağı). pg veriyi sessizce bozmaktansa metin olarak verir; sayıya çevirip çevirmemek senin kararın.

Bunun API'de ne demek olduğunu gör. Oyuncu başına maç sayısı ve ortalama kill veren bir uç nokta ekle; LEFT JOIN maçı olmayan Kaan'ı da listeye katar (Ders 36). Bu dersteki yeni route'ların hepsini app.listen'ın üstüne ekle; kod bloklarındaki satır numaraları dosyanın o anki hâlini gösteriyor:

app.js
app.get("/istatistik", async (req, res) => {    const { rows } = await pool.query(`        SELECT o.ad, count(m.id) AS mac, round(avg(m.kill), 1) AS ortalama_kill        FROM oyuncular o        LEFT JOIN maclar m ON m.oyuncu_id = o.id        GROUP BY o.id, o.ad        ORDER BY o.id    `)    res.json(rows)})
İkinci terminal
PS C:\kod\pg-api> curl.exe http://localhost:3000/istatistik[{"ad":"Deniz","mac":"3","ortalama_kill":"7.0"},{"ad":"Ece","mac":"2","ortalama_kill":"9.0"},{"ad":"Mert","mac":"1","ortalama_kill":"2.0"},{"ad":"Selin","mac":"1","ortalama_kill":"4.0"},{"ad":"Kaan","mac":"0","ortalama_kill":null}]

Uzun SQL'i ters tırnakla yazdık; ters tırnaklı metin birden çok satıra yayılabilir (Ders 9). Yanıttaki her sayı tırnak içinde. Bu API'yi kullanan biri mac + 1 hesaplarsa "31" elde eder (Ders 1'deki metin + sayı tuzağı). Düzeltmenin en temiz yeri SQL'in kendisi:

app.js
app.get("/istatistik", async (req, res) => {    const { rows } = await pool.query(`        SELECT o.ad, count(m.id)::int AS mac,               round(avg(m.kill), 1)::float AS "ortalamaKill"        FROM oyuncular o        LEFT JOIN maclar m ON m.oyuncu_id = o.id        GROUP BY o.id, o.ad        ORDER BY o.id    `)    res.json(rows)})
İkinci terminal
PS C:\kod\pg-api> curl.exe http://localhost:3000/istatistik[{"ad":"Deniz","mac":3,"ortalamaKill":7},{"ad":"Ece","mac":2,"ortalamaKill":9},{"ad":"Mert","mac":1,"ortalamaKill":2},{"ad":"Selin","mac":1,"ortalamaKill":4},{"ad":"Kaan","mac":0,"ortalamaKill":null}]
  • :: Postgres'te tip çevirme işaretidir: count(m.id)::int "sonucu integer yap" demek. Bir oyuncunun maç sayısı integer'a rahatça sığar.
  • ::float ondalıklı sayıyı JavaScript sayısına uyan bir tipe (double precision) çevirir. İstatistikte birkaç basamak kaybı sorun değil; para gibi kuruşun önemli olduğu yerde numeric'i metin olarak bırak.
  • AS "ortalamaKill": JSON'daki anahtarın adı. SQL'de sütunlar snake_case, JavaScript'te adlar camelCase yazılır; takma ad (Ders 34) ikisini buluşturur.
  • Kaan'ın ortalaması null: hiç maçı yok, avg boş gruba NULL verir. Ona 0 demek yanlış olurdu; null "bilinmiyor" demek.
Sık hataSQL'deki çift tırnak JavaScript metnini kapatıyor

Ne zaman çıkar? İçinde "oyuncuId" gibi çift tırnaklı bir takma ad geçen SQL'i çift tırnaklı bir JavaScript metnine yazınca. JavaScript metni ilk iç tırnakta biter sanır.

deneme.js
import { pool } from "./db.js" const sonuc = await pool.query("SELECT oyuncu_id AS "oyuncuId", sampiyon FROM maclar LIMIT 1")console.log(sonuc.rows)await pool.end()
Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsfile:///C:/kod/pg-api/deneme.js:3const sonuc = await pool.query("SELECT oyuncu_id AS "oyuncuId", sampiyon FROM maclar LIMIT 1")                               ^^^^^^^^^^^^^^^^^^^^^^ SyntaxError: missing ) after argument list    ...

Nasıl düzelir? Metni ters tırnakla aç ve kapat: pool.query(`SELECT oyuncu_id AS "oyuncuId", sampiyon FROM maclar LIMIT 1`). Sonuç [ { oyuncuId: 1, sampiyon: 'Ahri' } ] olur. Ters tırnağın içine ${...} ile değer koymaya kalkma; neden kalkmaman gerektiği iki bölüm sonra.

Havuz: bağlantıları paylaşmak

Postgres'e bağlanmak ucuz bir iş değil. Bağlantı açılınca iki taraf önce tanışır: trafik TLS ile şifrelenir, kullanıcı adıyla şifre denetlenir, ayarlar paylaşılır. Bu tanışmaya el sıkışma (handshake) denir. node-postgres'in belgesine göre el sıkışma tek başına 20–30 milisaniye sürebilir; Neon başka bir ülkedeki bir sunucudaysa süre uzar.

Üstelik veritabanı aynı anda sınırlı sayıda bağlantı kabul eder: Neon'da havuzsuz bağlantı sınırı, makinenin boyuna göre yaklaşık 100 ile 4.000 arasında.

Her istekte yeni bağlantı açıp kapatmak hem yavaş hem israf. Bağlantı havuzu (connection pool) birkaç bağlantıyı açık tutar ve gelen sorgulara sırayla ödünç verir. Pool bağlantıları gerektikçe açar; varsayılan olarak en çok 10 tane açar ve 10 saniye boşta kalan bağlantıyı kapatır.

İstek A İstek B İstek C Havuz en çok 10 bağlantı kullanımda boşta henüz açılmadı 5 açık bağlantı Postgres Neon pool.query() al, sorgula, geri ver: kendiliğinden pool.connect() bağlantıyı sen alırsın client.release() geri vermek de senin işin
Şekil 37.3 Havuz bağlantıları gerektikçe açar, en çok 10 tane. Gelen istek boştaki bir bağlantıyı ödünç alır, sorgusu bitince geri verir; 10 saniye boşta kalan bağlantı kapanır.

Havuzu kullanmanın iki yolu var:

  • pool.query(...): boştaki bir bağlantıyı alır, sorguyu çalıştırır, bağlantıyı geri verir. Tek bir sorgu için hep bunu kullan.
  • pool.connect() ile client.release(): bağlantıyı sen alırsın, birden çok sorguyu aynı bağlantıda çalıştırırsın ve işin bitince geri verirsin. Transaction için gerekir, çünkü BEGIN, sorgular ve COMMIT aynı bağlantıda olmalı. Göç bölümünde kullanacaksın.

Havuzun iki klasik yanlış kullanımı var. İkisi de ilk denemede çalışır, yük gelince çöker.

Sık hataremaining connection slots are reserved for roles with the SUPERUSER attribute

Ne zaman çıkar? Havuzu route'un içinde, her istekte yeniden kurunca. Her istek yeni bir havuz ve yeni bir bağlantı açar. Bu bağlantılar ancak 10 saniye boşta kaldıktan sonra kapanır; kısa sürede çok istek gelirse birikirler ve veritabanının sınırı dolar.

app.js✗ Yanlış
app.get("/oyuncular", async (req, res) => {    const pool = new Pool({ connectionString: process.env.DATABASE_URL })    const { rows } = await pool.query("SELECT id, ad, bolge FROM oyuncular ORDER BY id")    res.json(rows)})

Bu kodun çalışması için dosyanın başında import { Pool } from "pg" satırı da olmalı. Aşağıdaki komut aynı isteği 20 kez atar ve yalnızca durum kodlarını yan yana yazar (-o NUL gövdeyi atar, -w yalnız kodu yazdırır). Denemede veritabanının bağlantı sınırını bilerek 20'ye indirdik; 3'ü yöneticiye ayrıldığı için 18. istek reddedildi:

İkinci terminal
PS C:\kod\pg-api> (1..20 | ForEach-Object { curl.exe -s -o NUL -w "%{http_code}" http://localhost:3000/oyuncular }) -join " "200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 500 500 500
Sunucunun terminali
error: remaining connection slots are reserved for roles with the SUPERUSER attribute    ...  severity: 'FATAL',  code: '53300',  ...

Neon'da sınır çok daha yüksek (havuzlu adreste 10.000 istemci bağlantısı) ama mantık aynı: açık bağlantı sayısı istek sayısıyla birlikte büyür ve yük artınca bir yerde sınır dolar. Sınıra ulaşmasan bile her istek yeni bir el sıkışmanın bedelini öder.

Nasıl düzelir? Havuzu bir kez, db.js'de kur ve her yerde onu içe aktar. Route'un içinde new Pool görürsen yanlış yerdedir.

Sık hata11. istekte sunucu donuyor: client.release() unutuldu

Ne zaman çıkar? pool.connect() ile bağlantı alıp geri vermeyince. Her istek havuzdan bir bağlantı eksiltir; on istekten sonra havuz boşalır ve sıradaki istek, hiç gelmeyecek bir bağlantıyı sonsuza kadar bekler. Terminalde hata yok; tarayıcı sayfayı yüklemeye devam eder.

app.js✗ Yanlış
app.get("/oyuncular", async (req, res) => {    const client = await pool.connect()    const { rows } = await client.query("SELECT id, ad, bolge FROM oyuncular ORDER BY id")    res.json(rows)})
İkinci terminal
PS C:\kod\pg-api> (1..10 | ForEach-Object { curl.exe -s -o NUL -w "%{http_code}" http://localhost:3000/oyuncular }) -join " "200 200 200 200 200 200 200 200 200 200PS C:\kod\pg-api> curl.exe --max-time 8 http://localhost:3000/oyuncularcurl: (28) Operation timed out after 8008 milliseconds with 0 bytes received

--max-time 8, curl'e "en çok 8 saniye bekle" der; o olmasa komut hiç bitmezdi.

Nasıl düzelir? Tek sorgu için pool.query kullan, bağlantıyla hiç uğraşma. Bağlantıyı gerçekten kendin alman gerekiyorsa (transaction) geri vermeyi finally'ye yaz (Ders 13); sorgu hata verse bile çalışır:

app.js✓ Doğru
app.get("/oyuncular", async (req, res) => {    const client = await pool.connect()    try {        const { rows } = await client.query("SELECT id, ad, bolge FROM oyuncular ORDER BY id")        res.json(rows)    } finally {        client.release()    }})

Bu hâliyle 25 istek de 200 döndü.

Sağlam bir db.js

Gördüğün iki hatayı ve henüz görmediğin bir üçüncüsünü db.js'e eklenen birkaç satırla daha anlaşılır hâle getirebilirsin:

db.js
import { Pool } from "pg" if (!process.env.DATABASE_URL) {    throw new Error("DATABASE_URL tanımlı değil: node --env-file=.env ile mi çalıştırdın?")} export const pool = new Pool({    connectionString: process.env.DATABASE_URL,    connectionTimeoutMillis: 5000}) pool.on("error", (hata) => {    console.error("Havuzdaki boşta bekleyen bir bağlantı koptu:", hata.message)})

3–5. satırlar: erken ve açık hata (Ders 13). --env-file'ı unutursan artık kafa karıştıran ECONNREFUSED ::1:5432 yerine ne yapman gerektiğini söyleyen bir mesaj alırsın:

Terminal — PowerShell
PS C:\kod\pg-api> node deneme.jsfile:///C:/kod/pg-api/db.js:4    throw new Error("DATABASE_URL tanımlı değil: node --env-file=.env ile mi çalıştırdın?")          ^ Error: DATABASE_URL tanımlı değil: node --env-file=.env ile mi çalıştırdın?    at file:///C:/kod/pg-api/db.js:4:11    ...

9. satır: bekleme sınırı. connectionTimeoutMillis: 5000, bir bağlantı için en çok 5 saniye beklenmesini söyler: havuzdan boş bir bağlantının gelmesi de, yeni bir bağlantının kurulması da bu sürede bitmeli. Varsayılan değer 0, yani "sonsuza kadar bekle". release unutulmuş uygulamada 11. istek artık donmaz; 5 saniye kadar sonra 500 döner ve sunucunun terminali sebebini yazar:

Sunucunun terminali
Error: timeout exceeded when trying to connect    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at async file:///C:/kod/pg-api/app.js:9:20

Mesaj "bağlanmaya çalışırken süre doldu" diyor, ama asıl sebep çoğu zaman ağ değil, geri verilmemiş bağlantılardır. Bu mesajı görünce önce pool.connect() geçen satırlara bak.

12–14. satırlar: boştaki bağlantının kopması. Havuz bağlantıları açık tutar ve o sırada sen hiçbir şey yapmazken bağlantı kopabilir: veritabanı yeniden başlatılabilir, ağ gidip gelebilir. Havuz bunu bir error olayıyla bildirir (Ders 22'deki olaylar gibi). Dinleyen yoksa Node bütün programı kapatır.

Sık hataUnhandled 'error' event ve terminating connection due to administrator command

Ne zaman çıkar? Havuzda boşta bekleyen bir bağlantıyı veritabanı tarafı kapatınca ve havuzun error olayını dinleyen kimse yokken. Aşağıdaki küçük betik bir sorgu atıp üç saniye bekliyor; o arada bağlantıyı veritabanı tarafından kendimiz kopardık. Neon da bakım, yeniden başlatma ya da uykuya geçme sırasında açık bağlantıları kapatır; o zaman da genellikle bu mesajı görürsün.

bekle.js
import { pool } from "./db.js" await pool.query("SELECT 1")console.log("Sorgu bitti, bağlantı havuzda bekliyor")setTimeout(() => console.log("Program hâlâ çalışıyor"), 3000)
Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env bekle.jsSorgu bitti, bağlantı havuzda bekliyornode:events:505    throw er; // Unhandled 'error' event    ^ error: terminating connection due to administrator command    ...Emitted 'error' event on BoundPool instance at:  ...  code: '57P01',  ... Node.js v24.20.0

Nasıl düzelir? pool.on("error", ...) ile dinleyici ekle. Aynı deneme, dinleyiciyle şöyle biter:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env bekle.jsSorgu bitti, bağlantı havuzda bekliyorHavuzdaki boşta bekleyen bir bağlantı koptu: terminating connection due to administrator commandProgram hâlâ çalışıyor

Havuz kopan bağlantıyı atar ve bir sonraki sorguda yenisini açar. Bir sunucu, boştaki bir bağlantı koptu diye kapanmamalı.

SQL injection: kullanıcının yazdığı metin

Ders 34'te SQL Editor'de ' OR '1'='1'in bütün satırları açtığını gördün; bu saldırının adını da Ders 31'de öğrenmiştin: SQL injection (Ders 31). Şimdi aynı hatayı gerçek bir API'de yapıp sonucunu göreceksin. Oyuncuyu adıyla arayan bir uç nokta ekle; şablon metinle (Ders 9), ilk akla gelen yoldan:

app.js✗ Yanlış
app.get("/ara", async (req, res) => {    const sql = `SELECT id, ad, bolge FROM oyuncular WHERE ad = '${req.query.ad}'`    console.log(sql)    const { rows } = await pool.query(sql)    res.json(rows)})

Önce masum bir istek, sonra adı bir SQL parçası olan bir istek. Adreste boşluk olamaz; %20 boşluğun adresteki yazılışıdır. PowerShell ' ve ; işaretlerini kendisi yorumlamasın diye adresi çift tırnağa al:

İkinci terminal
PS C:\kod\pg-api> curl.exe "http://localhost:3000/ara?ad=Deniz"[{"id":1,"ad":"Deniz","bolge":"TR"}]PS C:\kod\pg-api> curl.exe "http://localhost:3000/ara?ad=x'%20OR%20'1'='1"[{"id":1,"ad":"Deniz","bolge":"TR"},{"id":2,"ad":"Ece","bolge":"TR"},{"id":3,"ad":"Mert","bolge":"EUW"},{"id":4,"ad":"Selin","bolge":"TR"},{"id":5,"ad":"Kaan","bolge":"EUW"}]
Sunucunun terminali
SELECT id, ad, bolge FROM oyuncular WHERE ad = 'Deniz'SELECT id, ad, bolge FROM oyuncular WHERE ad = 'x' OR '1'='1'

İkinci istekte kullanıcı bir ad değil, SQL yazdı. Şablon metin onu tırnakların arasına olduğu gibi yapıştırdı ve sorgunun anlamı değişti. Postgres'e giden sorgu şuydu; SQL Editor'de çalıştırırsan aynı beş satır gelir:

SQL
SELECT id, ad, bolge FROM oyuncular WHERE ad = 'x' OR '1'='1';
idadbolge
1DenizTR
2EceTR
3MertEUW
4SelinTR
5KaanEUW

Daha kötüsü de var. pg, parametresiz bir sorgu metninde noktalı virgülle ayrılmış birden çok komut görürse hepsini sırayla çalıştırır. -- ise SQL'de yorum başlatır ve sondaki fazla tırnağı etkisiz bırakır:

İkinci terminal
PS C:\kod\pg-api> curl.exe -i "http://localhost:3000/ara?ad=x';%20DELETE%20FROM%20maclar;%20--"HTTP/1.1 200 OKX-Powered-By: ExpressContent-Type: application/json; charset=utf-8Date: Sun, 11 Oct 2026 17:03:32 GMTConnection: keep-aliveKeep-Alive: timeout=5Content-Length: 0 PS C:\kod\pg-api> curl.exe http://localhost:3000/istatistik[{"ad":"Deniz","mac":0,"ortalamaKill":null},{"ad":"Ece","mac":0,"ortalamaKill":null},{"ad":"Mert","mac":0,"ortalamaKill":null},{"ad":"Selin","mac":0,"ortalamaKill":null},{"ad":"Kaan","mac":0,"ortalamaKill":null}]
Sunucunun terminali
SELECT id, ad, bolge FROM oyuncular WHERE ad = 'x'; DELETE FROM maclar; --'

Yanıt boş bir 200 OK: hata yok, alarm yok. (Gövde boş, çünkü pg birden çok komut çalıştırınca her komut için bir sonuç içeren bir dizi döndürür; dizinin rows alanı olmadığı için res.json'a undefined gitti.) Ama maclar tablosunun bütün satırları silindi. Bunu kendi veritabanında denediysen maçları geri getirmek için veri.sql'deki ikinci INSERT'i SQL Editor'de yeniden çalıştır. Bu denemeleri yalnız kendi veritabanında yap; başkasının sisteminde denemek suçtur.

Parametreli sorgu: $1, $2

Çözüm, kullanıcının değerini sorgu metnine hiç koymamak. Metinde değerin yerine bir yer tutucu ($1) yazarsın, değeri ayrı bir dizide verirsin:

app.js✓ Doğru
app.get("/ara", async (req, res) => {    const { rows } = await pool.query(        "SELECT id, ad, bolge FROM oyuncular WHERE ad = $1",        [req.query.ad]    )    res.json(rows)})
İkinci terminal
PS C:\kod\pg-api> curl.exe "http://localhost:3000/ara?ad=Deniz"[{"id":1,"ad":"Deniz","bolge":"TR"}]PS C:\kod\pg-api> curl.exe "http://localhost:3000/ara?ad=x'%20OR%20'1'='1"[]PS C:\kod\pg-api> curl.exe "http://localhost:3000/ara?ad=x';%20DELETE%20FROM%20maclar;%20--"[]PS C:\kod\pg-api> curl.exe http://localhost:3000/istatistik[{"ad":"Deniz","mac":3,"ortalamaKill":7},{"ad":"Ece","mac":2,"ortalamaKill":9},{"ad":"Mert","mac":1,"ortalamaKill":2},{"ad":"Selin","mac":1,"ortalamaKill":4},{"ad":"Kaan","mac":0,"ortalamaKill":null}]

İki saldırı da boş bir dizi döndürdü ve maçlar yerinde.

pg artık sorguyu iki ayrı parça hâlinde gönderiyor: önce yer tutuculu SQL metni, sonra değerler. Postgres metni değerlerden önce okur ve ne yapacağına karar verir; değer geldiğinde komut çoktan bellidir. x' OR '1'='1 artık yalnızca adı tuhaf bir oyuncu arayan bir metin. Böyle bir oyuncu yok, sonuç boş.

Metni birleştirmek kod WHERE ad = '${ad}' kullanıcının yazdığı x' OR '1'='1 Postgres'e giden tek metin WHERE ad = 'x' OR '1'='1' 5 satırın hepsi geldi girdi SQL'in parçası oldu Parametreli sorgu kod "… WHERE ad = $1", [ad] kullanıcının yazdığı x' OR '1'='1 Postgres'e giden iki ayrı parça SQL: WHERE ad = $1 $1 = "x' OR '1'='1" 0 satır geldi girdi yalnızca veri olarak kaldı
Şekil 37.4 Solda değer SQL metninin içine yapıştırılıyor ve komutun parçası oluyor. Sağda metin ile değer ayrı gidiyor; değer yalnızca veri olarak kalıyor.

Dört kural:

  • Numaralar diziyle eşleşir: $1 dizinin ilk elemanı, $2 ikincisi. Sırayı karıştırırsan değerler yanlış sütunlara gider.
  • Yer tutucuyu tırnağa koyma. '$1' artık bir yer tutucu değil, içinde dolar işareti olan bir metindir (aşağıdaki hata kartı).
  • Yalnız değerler parametre olabilir. Tablo ya da sütun adı, ASC ya da DESC gibi SQL parçaları olamaz; onları izin verdiğin seçenekler arasından kendin seçersin (Görev 4).
  • Şablon metin parametre değildir. Ters tırnak ve ${} metni birleştirir; korumaz.
JavaScript✗ Yanlış
pool.query(`SELECT id, ad FROM oyuncular WHERE ad = '${ad}'`)
JavaScript✓ Doğru
pool.query("SELECT id, ad FROM oyuncular WHERE ad = $1", [ad])

Aynı fikirle tek bir oyuncuyu getiren uç noktayı yaz. Adresteki :id her zaman metin gelir (Ders 28); onu sayıya çevirmene gerek yok, Postgres $1'i sütunun tipine (integer) göre okur:

app.js
app.get("/oyuncular/:id", async (req, res) => {    const { rows } = await pool.query(        "SELECT id, ad, bolge FROM oyuncular WHERE id = $1",        [req.params.id]    )    if (rows.length === 0) {        return res.status(404).json({ hata: "Oyuncu bulunamadı" })    }    res.json(rows[0])})
İkinci terminal
PS C:\kod\pg-api> curl.exe http://localhost:3000/oyuncular/3{"id":3,"ad":"Mert","bolge":"EUW"}PS C:\kod\pg-api> curl.exe http://localhost:3000/oyuncular/99{"hata":"Oyuncu bulunamadı"}

Satır gelmezse rows boş bir dizidir, rows[0] ise undefined. undefined'ı JSON'a çevirmek yerine 404 döndürüyoruz. Tek satır beklediğin her yerde bu iki satırlık kalıbı kullanacaksın.

Sık hatabind message supplies 1 parameters, but prepared statement "" requires 0

Ne zaman çıkar? Dizideki değer sayısı ile metindeki yer tutucu sayısı tutmayınca. En sık sebebi yer tutucuyu tırnağa almak: '$1' bir metin olduğu için sorguda hiç yer tutucu kalmaz, ama sen bir değer gönderirsin.

deneme.js
import { pool } from "./db.js" const ad = "Deniz"const { rows } = await pool.query("SELECT id, ad FROM oyuncular WHERE ad = '$1'", [ad])console.log(rows)await pool.end()
Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env deneme.jsC:\kod\pg-api\node_modules\pg-pool\index.js:45    Error.captureStackTrace(err)          ^ error: bind message supplies 1 parameters, but prepared statement "" requires 0    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/deneme.js:4:18 {  length: 137,  severity: 'ERROR',  code: '08P01',  ...}

Mesaj "1 değer gönderdin ama sorgu 0 değer istiyor" diyor. Tersini yapınca, yani $1 ve $2 yazıp tek değer gönderince bind message supplies 1 parameters, but prepared statement "" requires 2 alırsın.

Nasıl düzelir? Yer tutucuyu tırnaksız yaz (WHERE ad = $1) ve dizideki değerleri yer tutucularla tek tek eşleştir.

Sık hatainvalid input syntax for type integer: "abc"

Ne zaman çıkar? Sayı bekleyen bir sütunu, sayıya çevrilemeyen bir değerle sorgulayınca. Adres /oyuncular/abc olunca $1 "abc" olur ve Postgres onu integer'a çeviremez. Henüz hata yakalayıcın olmadığı için Express kendi varsayılan sayfasını gönderir: 500 ve yığın izli bir HTML.

İkinci terminal
PS C:\kod\pg-api> curl.exe -i http://localhost:3000/oyuncular/abcHTTP/1.1 500 Internal Server ErrorX-Powered-By: ExpressContent-Security-Policy: default-src 'none'X-Content-Type-Options: nosniffContent-Type: text/html; charset=utf-8... <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Error</title></head><body><pre>error: invalid input syntax for type integer: &quot;abc&quot;<br> &nbsp; &nbsp;at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11<br> ...</pre></body></html>

Sunucu çökmedi; Express 5, async route'tan fırlayan hatayı kendisi yakaladı. Ama hatayı yapan istemciydi, sunucu değil; 500 yanlış kod.

Nasıl düzelir? Bu hatanın kodu 22P02. Bir sonraki bölümde Postgres'in hata kodlarını HTTP durum kodlarına çeviren bir hata yakalayıcı yazacaksın; 22P02 orada 400 olacak. İstersen sorgudan önce Number.isInteger(Number(req.params.id)) ile de doğrulayabilirsin (Ders 28).

Yazmak ve hata kodlarını çevirmek

Şimdi oyuncu ekleyen uç noktayı yaz. INSERT ... RETURNING (Ders 35) Postgres'in verdiği id ile birlikte yeni satırı geri döndürür:

app.js
app.post("/oyuncular", async (req, res) => {    const { ad, bolge } = req.body    const { rows } = await pool.query(        "INSERT INTO oyuncular (ad, bolge) VALUES ($1, $2) RETURNING id, ad, bolge",        [ad, bolge]    )    res.status(201).json(rows[0])})

İsteğin gövdesini Ders 27'deki gibi bir dosyadan gönder. VS Code'da arda.json dosyasına { "ad": "Arda", "bolge": "TR" }, deniz.json dosyasına { "ad": "Deniz", "bolge": "TR" } yaz:

İkinci terminal
PS C:\kod\pg-api> curl.exe -i --json "@arda.json" http://localhost:3000/oyuncularHTTP/1.1 201 CreatedX-Powered-By: ExpressContent-Type: application/json; charset=utf-8Content-Length: 33... {"id":6,"ad":"Arda","bolge":"TR"}PS C:\kod\pg-api> curl.exe -i --json "@deniz.json" http://localhost:3000/oyuncularHTTP/1.1 500 Internal Server ErrorX-Powered-By: ExpressContent-Security-Policy: default-src 'none'X-Content-Type-Options: nosniffContent-Type: text/html; charset=utf-8... <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Error</title></head><body><pre>error: duplicate key value violates unique constraint &quot;oyuncular_ad_key&quot;<br> &nbsp; &nbsp;at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11<br> ...</pre></body></html>

Arda eklendi. Deniz zaten var; UNIQUE kısıtı ikinci Deniz'i reddetti ve istemci yine yığın izli bir 500 aldı. Sunucunun terminali hatanın bütün alanlarını yazdı:

Sunucunun terminali
error: duplicate key value violates unique constraint "oyuncular_ad_key"    at C:\kod\pg-api\node_modules\pg-pool\index.js:45:11    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/app.js:46:22 {  length: 200,  severity: 'ERROR',  code: '23505',  detail: 'Key (ad)=(Deniz) already exists.',  hint: undefined,  ...  schema: 'public',  table: 'oyuncular',  ...  constraint: 'oyuncular_ad_key',  ...}

Bu bir obje ve alanlarını kodda okuyabilirsin: hata.code, hata.detail, hata.table, hata.constraint. En önemlisi code.

Bir API'nin işi, veritabanının dilini istemcinin diline çevirmektir. Sık göreceğin kodlar ve karşılıkları:

KodNe olduHTTP
23505UNIQUE sütuna var olan bir değer yazıldı409 Conflict
23503 (ekleme)olmayan bir kayda bağlanılmak istendi400 Bad Request
23503 (silme)bağlı kayıtları olan bir satır silinmek istendi409 Conflict
23502zorunlu (NOT NULL) sütun boş geldi400 Bad Request
23514CHECK kuralına uymayan değer geldi400 Bad Request
22P02değer, sütunun tipine çevrilemedi ("abc")400 Bad Request

409 Conflict'i Ders 26'da tanıdın, Ders 31'de alınmış bir kullanıcı adı için kendin de döndürdün: "İstek düzgün yazılmış ama sunucudaki mevcut durumla çakışıyor" demek. İkinci Deniz, var olan Deniz'le çakışır; istemci adı değiştirirse aynı istek geçer. 23503'ün iki yüzü var: maça olmayan bir oyuncuyu yazmak istemcinin hatasıdır (400); maçı olan bir oyuncuyu silmek ise mevcut durumla çakışmadır (409).

Çeviriyi ayrı bir dosyada, tek başına denenebilen bir fonksiyon olarak yaz (Ders 6'daki "her parçayı ayrı dene" alışkanlığı). hatalar.js adında bir dosya aç:

hatalar.js
export function pgHatasiniCevir(hata, metot) {    if (hata.code === "23505") {        return { durum: 409, mesaj: "Bu kayıt zaten var" }    }    if (hata.code === "23503") {        if (metot === "DELETE") {            return { durum: 409, mesaj: "Bu kayda bağlı başka kayıtlar var" }        }        return { durum: 400, mesaj: "Bağlandığı kayıt bulunamadı" }    }    if (hata.code === "22P02" || hata.code === "23502" || hata.code === "23514") {        return { durum: 400, mesaj: "Gönderilen değerlerden biri geçersiz" }    }    return null} console.log(pgHatasiniCevir({ code: "23505" }, "POST"))console.log(pgHatasiniCevir({ code: "23503" }, "POST"))console.log(pgHatasiniCevir({ code: "23503" }, "DELETE"))console.log(pgHatasiniCevir({ code: "22P02" }, "GET"))console.log(pgHatasiniCevir({ code: "42P01" }, "GET"))
Çıktı
{ durum: 409, mesaj: 'Bu kayıt zaten var' }{ durum: 400, mesaj: 'Bağlandığı kayıt bulunamadı' }{ durum: 409, mesaj: 'Bu kayda bağlı başka kayıtlar var' }{ durum: 400, mesaj: 'Gönderilen değerlerden biri geçersiz' }null

node hatalar.js ile çalıştır. Fonksiyon yalnız tanıdığı kodları çevirir, tanımadığına null döndürür. 42P01 "tablo yok" demek: bu istemcinin değil, kodun hatası; 500 kalmalı. Denedikten sonra alttaki beş console.log satırını sil; dosyada yalnız fonksiyon kalsın.

Şimdi fonksiyonu Express'in hata yakalayıcısına bağla. app.js'in en üstüne içe aktarmayı ekle:

app.js
import express from "express"import { pool } from "./db.js"import { pgHatasiniCevir } from "./hatalar.js"

app.listen'ın hemen üstüne de silme uç noktasını ve hata yakalayıcıyı ekle:

app.js
app.delete("/oyuncular/:id", async (req, res) => {    const { rowCount } = await pool.query("DELETE FROM oyuncular WHERE id = $1", [req.params.id])    if (rowCount === 0) {        return res.status(404).json({ hata: "Oyuncu bulunamadı" })    }    res.status(204).end()}) app.use((hata, req, res, next) => {    const cevap = pgHatasiniCevir(hata, req.method)    if (cevap) {        return res.status(cevap.durum).json({ hata: cevap.mesaj })    }    if (hata.status) {        return res.status(hata.status).json({ hata: hata.message })    }    console.error(hata)    res.status(500).json({ hata: "Sunucuda bir hata oluştu" })})
  • rowCount: DELETE'in sildiği satır sayısı. 0 ise öyle bir oyuncu yok: 404. Silme başarılıysa gövdesiz 204 No Content (Ders 26).
  • Dört parametreli app.use, Express'in hata yakalayıcısıdır (Ders 28). Express 5'te bir async route'un içinden fırlayan hata, try/catch yazmana gerek kalmadan buraya gelir. Bütün route'lardan sonra, app.listen'ın üstüne yazılır.
  • hata.status: express.json(), bozuk bir gövdeyi 400 durumlu bir hatayla bildirir; onu olduğu gibi geçiriyoruz.
  • Geri kalan her şey gerçekten sunucunun sorunudur: ayrıntıyı console.error ile terminale yaz, istemciye yalnız genel bir cümle gönder.

Dene. bolgesiz.json dosyasına { "ad": "Zeynep" } yaz; --json "Deniz" ise JSON olmayan bir gövde gönderir:

İkinci terminal
PS C:\kod\pg-api> curl.exe -i --json "@deniz.json" http://localhost:3000/oyuncularHTTP/1.1 409 ConflictX-Powered-By: ExpressContent-Type: application/json; charset=utf-8Content-Length: 30... {"hata":"Bu kayıt zaten var"}PS C:\kod\pg-api> curl.exe --json "@bolgesiz.json" http://localhost:3000/oyuncular{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\pg-api> curl.exe --json "Deniz" http://localhost:3000/oyuncular{"hata":"Unexpected token 'D', \"Deniz\" is not valid JSON"}PS C:\kod\pg-api> curl.exe http://localhost:3000/oyuncular/abc{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\pg-api> curl.exe -i -X DELETE http://localhost:3000/oyuncular/1HTTP/1.1 409 ConflictX-Powered-By: ExpressContent-Type: application/json; charset=utf-8Content-Length: 48... {"hata":"Bu kayda bağlı başka kayıtlar var"}PS C:\kod\pg-api> curl.exe -i -X DELETE http://localhost:3000/oyuncular/5HTTP/1.1 204 No ContentX-Powered-By: Express... PS C:\kod\pg-api> curl.exe -X DELETE http://localhost:3000/oyuncular/5{"hata":"Oyuncu bulunamadı"}

Her hata artık doğru kodla ve anlaşılır bir cümleyle dönüyor. Deniz'in maçları olduğu için silinemedi (409); maçı olmayan Kaan silindi (204); ikinci silme isteği onu bulamadı (404).

app.js'in son hâli, karşılaştırmak için:

app.js
import express from "express"import { pool } from "./db.js"import { pgHatasiniCevir } from "./hatalar.js" const app = express()const port = process.env.PORT || 3000app.use(express.json()) app.get("/oyuncular", async (req, res) => {    const { rows } = await pool.query("SELECT id, ad, bolge FROM oyuncular ORDER BY id")    res.json(rows)}) app.get("/istatistik", async (req, res) => {    const { rows } = await pool.query(`        SELECT o.ad, count(m.id)::int AS mac,               round(avg(m.kill), 1)::float AS "ortalamaKill"        FROM oyuncular o        LEFT JOIN maclar m ON m.oyuncu_id = o.id        GROUP BY o.id, o.ad        ORDER BY o.id    `)    res.json(rows)}) app.get("/ara", async (req, res) => {    const { rows } = await pool.query(        "SELECT id, ad, bolge FROM oyuncular WHERE ad = $1",        [req.query.ad]    )    res.json(rows)}) app.get("/oyuncular/:id", async (req, res) => {    const { rows } = await pool.query(        "SELECT id, ad, bolge FROM oyuncular WHERE id = $1",        [req.params.id]    )    if (rows.length === 0) {        return res.status(404).json({ hata: "Oyuncu bulunamadı" })    }    res.json(rows[0])}) app.post("/oyuncular", async (req, res) => {    const { ad, bolge } = req.body    const { rows } = await pool.query(        "INSERT INTO oyuncular (ad, bolge) VALUES ($1, $2) RETURNING id, ad, bolge",        [ad, bolge]    )    res.status(201).json(rows[0])}) app.delete("/oyuncular/:id", async (req, res) => {    const { rowCount } = await pool.query("DELETE FROM oyuncular WHERE id = $1", [req.params.id])    if (rowCount === 0) {        return res.status(404).json({ hata: "Oyuncu bulunamadı" })    }    res.status(204).end()}) app.use((hata, req, res, next) => {    const cevap = pgHatasiniCevir(hata, req.method)    if (cevap) {        return res.status(cevap.durum).json({ hata: cevap.mesaj })    }    if (hata.status) {        return res.status(hata.status).json({ hata: hata.message })    }    console.error(hata)    res.status(500).json({ hata: "Sunucuda bir hata oluştu" })}) app.listen(port, () => {    console.log(`API hazır: http://localhost:${port}`)})

Şema dosyası ve göç (migration)

Uygulama büyüdükçe tablolar da değişir. Diyelim ki oyuncuların seviyesini de tutmak istiyorsun. schema.sql'e bir sütun eklemek yetmez: Neon'daki tablo zaten kurulu ve içinde veri var. Dosyayı yeniden çalıştırırsan Postgres reddeder:

SQL
CREATE TABLE oyuncular (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    ad text NOT NULL UNIQUE,    bolge text NOT NULL);
Hata çıktısı
ERROR:  relation "oyuncular" already exists

Tabloyu silip yeniden kurmak verini götürür. SQL Editor'de elle ALTER TABLE çalıştırmak ise iz bırakmaz: üç ay sonra canlı veritabanında hangi değişikliğin yapıldığını, takım arkadaşının veritabanında neyin eksik olduğunu kimse bilemez. Çözüm, her değişikliği numaralı bir dosyaya yazmak ve bu dosyaları sırayla, her birini bir kez çalıştırmak.

Projende goclar adında bir klasör aç. İlk iki göç: oyunculara seviye sütunu ekle ve maçları oyuncuya göre arayan sorgular hızlansın diye oyuncu_id'ye indeks koy (Ders 36):

goclar/001_oyuncu_seviye.sql
ALTER TABLE oyuncular ADD COLUMN seviye integer NOT NULL DEFAULT 1 CHECK (seviye >= 1);
goclar/002_maclar_indeks.sql
CREATE INDEX maclar_oyuncu_id_idx ON maclar (oyuncu_id);

Adların başındaki sıfırlı numaralar sırayı belirler. Metinler harf harf karşılaştırılarak sıralanır (Ders 11); 1_..., 2_..., 10_... diye yazsaydın .sort() onları 10_, 1_, 2_ sırasına koyardı. Şimdi bu dosyaları uygulayan küçük betiği yaz:

goc.js
import { readdir, readFile } from "node:fs/promises"import { pool } from "./db.js" await pool.query(`    CREATE TABLE IF NOT EXISTS goc_gecmisi (        dosya text PRIMARY KEY,        uygulandi timestamptz NOT NULL DEFAULT now()    )`) const { rows } = await pool.query("SELECT dosya FROM goc_gecmisi")const uygulananlar = rows.map((satir) => satir.dosya)const dosyalar = (await readdir("goclar")).filter((ad) => ad.endsWith(".sql")).sort() for (const dosya of dosyalar) {    if (uygulananlar.includes(dosya)) {        continue    }    const sql = await readFile(`goclar/${dosya}`, "utf8")    const client = await pool.connect()    try {        await client.query("BEGIN")        await client.query(sql)        await client.query("INSERT INTO goc_gecmisi (dosya) VALUES ($1)", [dosya])        await client.query("COMMIT")        console.log("Uygulandı:", dosya)    } catch (hata) {        await client.query("ROLLBACK")        throw hata    } finally {        client.release()    }} console.log("Göçler tamam.")await pool.end()
  • 4–9. satırlar: goc_gecmisi tablosunu kurar. IF NOT EXISTS "yoksa kur, varsa dokunma" demek; betik her çalıştığında hata vermeden geçer.
  • 11–13. satırlar: uygulanmış dosyaların adlarını tablodan, klasördeki .sql dosyalarını diskten okur (Ders 29) ve ada göre sıralar (Ders 11).
  • 16–18. satırlar: tabloda adı olan dosyayı atlar. Her göç bir kez çalışır.
  • 20–32. satırlar: göç dosyası ve onun kaydı tek bir transaction içinde, aynı bağlantıda çalışır: ya ikisi birden olur ya hiçbiri. Bunun için pool.connect() gerekiyor. pool.query("BEGIN") yazsaydın havuz BEGIN'i bir bağlantıya, sonraki sorguyu başka bir bağlantıya verebilirdi. Bağlantı finally'de geri verilir.
  • 29. satır: throw hata betiği durdurur. Bozuk bir göçten sonra sıradakileri çalıştırmak, yamaları atlayarak yüklemek gibidir.
goclar/ 001_oyuncu_seviye.sql ✓ 002_maclar_indeks.sql ✓ 003_seviyeleri_doldur.sql yeni goc_gecmisi tablosu 001_oyuncu_seviye.sql 002_maclar_indeks.sql 003_seviyeleri_doldur.sql goc.js: tabloda olmayan dosyayı çalıştırır, sonra adını tabloya yazar. Uygulanmış bir dosya bir daha çalışmaz; her değişiklik yeni bir dosyadır.
Şekil 37.5 Uygulanmış göçler tabloda yazılı. goc.js yalnız tabloda olmayan dosyayı çalıştırır, sonra adını tabloya ekler.

Betiği iki kez çalıştır:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env goc.jsUygulandı: 001_oyuncu_seviye.sqlUygulandı: 002_maclar_indeks.sqlGöçler tamam.PS C:\kod\pg-api> node --env-file=.env goc.jsGöçler tamam.

İkinci çalıştırmada hiçbir şey olmadı; iki dosya da tabloda yazılı. Takım arkadaşın projeyi indirip aynı komutu kendi veritabanında çalıştırdığında ise ikisi de uygulanır. Herkesin veritabanı aynı yamaya gelir.

Şimdi üçüncü göçü yaz; oyuncuların gerçek seviyelerini dolduralım:

goclar/003_seviyeleri_doldur.sql
UPDATE oyuncular SET seviye = 87 WHERE ad = 'Deniz';UPDATE oyuncular SET seviye = 142 WHERE ad = 'Ece';UPDATE oyuncular SET seviye = 35 WHERE ad = 'Mert';UPDATE oyuncular SET seviye = 210 WHERE ad = 'Selin';

Yazarken üçüncü satırda seviye yerine sevye yazdığını düşün. Betik ilk iki UPDATE'i çalıştırır, üçüncüde hata alır:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env goc.jsC:\kod\pg-api\node_modules\pg\lib\client.js:694          Error.captureStackTrace(err)                ^ error: column "sevye" of relation "oyuncular" does not exist    at C:\kod\pg-api\node_modules\pg\lib\client.js:694:17    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)    at async file:///C:/kod/pg-api/goc.js:23:9 {  ...}

ROLLBACK sayesinde Deniz ile Ece'nin seviyesi de geri alındı: beş oyuncunun seviyesi hâlâ 1 ve goc_gecmisi'nde 003 yok. Dosya henüz uygulanmadığı için onu düzeltmek serbest. Düzelt ve yeniden çalıştır:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env goc.jsUygulandı: 003_seviyeleri_doldur.sqlGöçler tamam.

Göçlerle çalışırken dört kural:

  • Uygulanmış bir dosyayı değiştirme, adını da değiştirme. Değişiklik gerekiyorsa yeni bir dosya yaz (Görev 5'te adını değiştirmenin ne yaptığını göreceksin).
  • schema.sql ilk kurulumun hikâyesi olarak kalır; bundan sonraki her değişiklik goclar klasörüne gider. Sıfırdan başlayan bir projede ilk göç dosyası tabloları kuran dosyanın kendisi olur.
  • Canlı veritabanında göç çalıştırmadan önce yedek al (Ders 35). Neon'da veritabanının anında bir kopyasını (branch) açıp göçü önce orada deneyebilirsin.
  • Neon, göç araçları için havuzsuz (direct) bağlantıyı öneriyor; bazı araçlar havuzlu bağlantının desteklemediği oturum özelliklerini kullanıyor. Connect penceresinde Connection pooling anahtarını kapatınca adresin -pooler'sız hâli gelir. Bizim betik o özelliklerin hiçbirini kullanmıyor.

Gerçek projelerde bu betiği kendin yazmazsın; aynı işi yapan hazır araçlar var: node-pg-migrate, Drizzle'ın drizzle-kit'i, Prisma'nın Prisma Migrate'i. Hepsinin çekirdeği bu derstekiyle aynı: numaralı dosyalar ve uygulananları tutan bir tablo.

Neon'un sunucusuz sürücüsü

Neon'un kendi sürücüsü de var: @neondatabase/serverless (bu ders yazılırken 1.2.0). pg gibi kalıcı bir bağlantı açmak yerine sorguyu bir HTTP isteğiyle (ya da WebSocket üzerinden) gönderir. Ders 32'de gördüğün sunucusuz ortamlar için yapılmıştır: her istekte yeniden başlayan kısa ömürlü fonksiyonlarda havuz yaşayamaz, bazı ortamlarda kalıcı bağlantı açmak hiç mümkün değildir.

JavaScript
import { neon } from "@neondatabase/serverless" const sql = neon(process.env.DATABASE_URL)const ad = "x' OR '1'='1"const satirlar = await sql`SELECT id, ad FROM oyuncular WHERE ad = ${ad}`

Sorgu ters tırnakla yazılmış, ama başında sql adı var: bu bir etiketli şablon (tagged template). JavaScript bu kez metni kendisi birleştirmez; parçaları ve değerleri sql fonksiyonuna ayrı ayrı verir, sürücü de ${ad}'ı $1'e çevirir. Sürücünün içine bakıp göndereceği sorguyu yazdırdığımızda (ağa hiç çıkmadan) gördüğümüz buydu:

Çıktı
{  query: 'SELECT id, ad FROM oyuncular WHERE ad = $1',  params: [ "x' OR '1'='1" ]}

Yani başında sql olan şablon güvenli, pool.query(`... ${ad} ...`) güvenli değil. Aradaki tek fark baştaki o sözcük; kodu okurken dikkat et. Sürücünün 1.0 sürümünden beri sql'i düz fonksiyon gibi çağırmak hata verir:

Çıktı
This function can now be called only as a tagged-template function: sql`SELECT ${value}`, not sql("SELECT $1", [value], options). For a conventional function call with value placeholders ($1, $2, etc.), use sql.query("SELECT $1", [value], options).

Hangisini ne zaman kullanmalı? Neon'un kendi rehberi, sürekli çalışan bir sunucu için (bu dersteki Express API'si gibi) pg ve bağlantı havuzunu öneriyor: havuz istekler arasında yaşadığı için en hızlı yol bu. Her isteği ayrı, kısa ömürlü bir fonksiyonun karşıladığı ortamlarda Neon'un sürücüsü öne çıkar. Blok 6'da Next.js ile veritabanına bağlanırken bu ayrımı yeniden düşüneceksin.

ORM'leri tanımak

Bu derste SQL'i elle yazdın, sonucu elle JSON'a çevirdin, göçleri elle numaralandırdın. Birçok proje bu işlerin bir kısmını bir ORM'e bırakır.

İki yaygın örnek. Kurmana gerek yok; nasıl göründüklerini tanı. Drizzle'da tablo kodda tarif edilir ve sorgu SQL'e çok benzer:

JavaScript
import { pgTable, integer, text } from "drizzle-orm/pg-core"import { drizzle } from "drizzle-orm/node-postgres"import { eq } from "drizzle-orm" const oyuncular = pgTable("oyuncular", {    id: integer().primaryKey().generatedAlwaysAsIdentity(),    ad: text().notNull().unique(),    bolge: text().notNull()}) const db = drizzle(process.env.DATABASE_URL)const trOyunculari = await db.select().from(oyuncular).where(eq(oyuncular.bolge, "TR"))

Bu örneği drizzle-orm 0.45.4 ile kendi veritabanımızda çalıştırdık; TR bölgesindeki oyuncular pg'deki gibi obje dizisi olarak geldi. Sorgunun sonuna await yerine .toSQL() eklersen Drizzle'ın Postgres'e göndereceği SQL'i görürsün. Değer yine metnin içinde değil, ayrı bir parametre:

Çıktı
{  sql: 'select "id", "ad", "bolge" from "oyuncular" where "oyuncular"."bolge" = $1',  params: [ 'TR' ]}

Prisma'da tablolar kendi şema dosyasında (schema.prisma) tarif edilir; Prisma bu dosyadan sana özel bir sorgu kütüphanesi üretir. Prisma 7'de bir sorgu şöyle görünür:

JavaScript
const trOyunculari = await prisma.oyuncu.findMany({ where: { bolge: "TR" } })

Bu ders yazılırken Prisma'nın 8. sürümü aday sürümdeydi (release candidate) ve npm install prisma bu aday sürümü kuruyordu. Sorgu yazımı da değişiyor: aynı sorgu .where({ bolge: "TR" }).all() biçimini alıyor. Kullanacağın gün güncel belgesine bak.

pg ve elle SQLDrizzlePrisma
Sorguyu nasıl yazarsınSQL metniSQL'e benzeyen JavaScriptKendi sorgu yazımı
Göç dosyalarıElle (bu dersteki gibi)drizzle-kit üretirPrisma Migrate üretir
TypeScript tipleriKendin yazarsınTablodan gelirŞemadan gelir
Çalışan SQLSenin yazdığınYazdığına çok yakınArka planda üretilir

Ne zaman hangisi? SQL'i öğrenirken ve küçük projelerde pg ile elle SQL: ne olduğunu her an görürsün. TypeScript'e geçtiğinde (Blok 6), tablolarının tipleri koda kendiliğinden gelsin istediğinde ve ekip büyüdüğünde ORM'ler işini kolaylaştırır; iş ilanlarında Prisma ve Drizzle adlarını sık göreceksin.

Hangisini seçersen seç iki şey değişmez. Birincisi, ORM'ler değerleri arka planda parametre olarak gönderir ama "ham SQL" kapıları (adında çoğu zaman raw ya da unsafe geçer) bu korumayı kaldırır. İkincisi, yavaş bir sorguyu düzeltmek için yine SQL okuman gerekir.

Görevler

Görevler esyalar adlı yeni bir tabloyla çalışıyor. SQL Editor'de pgapi veritabanı seçiliyken şu betiği çalıştır ve esyalar.sql adıyla projene kaydet:

esyalar.sql
CREATE TABLE esyalar (    id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    ad text NOT NULL UNIQUE,    fiyat integer NOT NULL CHECK (fiyat >= 0),    tur text NOT NULL); INSERT INTO esyalar (ad, fiyat, tur) VALUES    ('Doran Kılıcı', 450, 'saldırı'),    ('Doran Kalkanı', 450, 'savunma'),    ('Uzun Kılıç', 350, 'saldırı'),    ('Can İksiri', 50, 'tüketim'),    ('Büyü Kitabı', 435, 'büyü'),    ('Sonsuzluk Kılıcı', 3450, 'saldırı');

Görev 1 ve 2 için ayrı dosyalar yazıp node --env-file=.env dosya.js ile çalıştır; Görev 3 ve 4'te app.js'e route ekle. Önce kendin dene, takılırsan ipuçlarını sırayla aç.

Görev 1

Ucuz eşyalar

ucuz.js adında bir dosya yaz. Dosyanın başında const sinir = 400 olsun; fiyatı bu sınıra eşit ya da küçük eşyaları ucuzdan pahalıya sıralayıp her birini bir satırda yazdırsın. Sınırı sorgu metnine yazmak yok, parametre olarak gönder. Hiç eşya yoksa Bu fiyata eşya yok yazsın. sinir 400 iken beklenen çıktı:

Metin
Can İksiri: 50 altınUzun Kılıç: 350 altın
İpucu 1

deneme.js'in kalıbını kullan: pool içe aktar, await pool.query(...), en sonda await pool.end(). Koşul WHERE fiyat <= $1, dizi [sinir].

İpucu 2

Satırları for...of ile gez (Ders 5) ve şablon metinle yazdır. "Hiç yok" durumunu rows.length === 0 ile yakala. Aynı fiyatlı iki eşya olabileceği için sıralamaya ikinci bir ölçüt ekle: ORDER BY fiyat, ad.

Çözümü göster
ucuz.js
import { pool } from "./db.js" const sinir = 400const { rows } = await pool.query(    "SELECT ad, fiyat FROM esyalar WHERE fiyat <= $1 ORDER BY fiyat, ad",    [sinir])if (rows.length === 0) {    console.log("Bu fiyata eşya yok")}for (const esya of rows) {    console.log(`${esya.ad}: ${esya.fiyat} altın`)}await pool.end()
Çıktı
Can İksiri: 50 altınUzun Kılıç: 350 altın

Sınır denemesi: sinir 50 olunca yalnız Can İksiri: 50 altın gelir (<= eşitliği de kapsar). 49 ve 0'da Bu fiyata eşya yok yazar. 3450'de altı eşyanın hepsi gelir; iki Doran eşyası da 450 altın olduğu için aralarındaki sırayı ad belirler.

Görev 2

Sayılar sayı olsun

tur-ozet.js adında bir dosya yaz. Dosyanın başında const tur = "saldırı" olsun. Tek bir sorguyla o türdeki eşyaların sayısını ve ortalama fiyatını (tam sayıya yuvarlanmış) bulsun ve satırı olduğu gibi yazdırsın: console.log(rows[0]). Değerler metin değil, sayı olmalı. Beklenen çıktı:

Metin
{ adet: 3, ortalama: 1417 }
İpucu 1

count(*) ve round(avg(fiyat)) kullan, türü $1 ile gönder. İlk denemende çıktı büyük ihtimalle { adet: '3', ortalama: '1417' } olacak: tek tırnaklara dikkat.

İpucu 2

Çeviriyi SQL'de yap: count(*)::int, round(avg(fiyat))::int. Takma adları AS adet ve AS ortalama ile ver.

Çözümü göster
tur-ozet.js
import { pool } from "./db.js" const tur = "saldırı"const { rows } = await pool.query(    "SELECT count(*)::int AS adet, round(avg(fiyat))::int AS ortalama FROM esyalar WHERE tur = $1",    [tur])console.log(rows[0])await pool.end()
Çıktı
{ adet: 3, ortalama: 1417 }

Üç saldırı eşyasının ortalaması 1416,67; round onu 1417 yapar. Sınır denemesi: "savunma" için { adet: 1, ortalama: 450 } gelir. Olmayan bir tür ("yok") için { adet: 0, ortalama: null } gelir: count boş gruba 0, avg ise NULL verir. null'ı 0'a çevirmeye kalkma; "ortalama 0 altın" yanlış bir bilgi olurdu.

Görev 3

Eşya uç noktaları

app.js'e iki route ekle (hata yakalayıcının üstüne):

  • GET /esyalar/:id: eşyayı {"id":1,"ad":"Doran Kılıcı","fiyat":450,"tur":"saldırı"} biçiminde döndürsün; yoksa 404 ve {"hata":"Eşya bulunamadı"}.
  • POST /esyalar: gövdedeki ad, fiyat ve tur ile eşya eklesin, 201 ile yeni satırı döndürsün.

Sonra şunları dene: /esyalar/1, /esyalar/99, /esyalar/abc; aynı eşyayı iki kez ekle (ikincisi 409); fiyatı -1 olan bir eşya gönder (400). Sınır olarak /esyalar/0, /esyalar/1.5 ve /esyalar/2147483648'i de dene. Biri 500 verecek: sunucunun terminalindeki hata kodunu bul ve hatalar.js'i, bu istek de 400 dönecek şekilde düzelt.

İpucu 1

İki route da derste yazdığın /oyuncular/:id ve POST /oyuncular'ın kalıbında. Hata yakalayıcıya dokunmana gerek yok; 409 ve 400'ler kendiliğinden gelecek.

İpucu 2

2147483648, integer tipinin taşıyabileceği en büyük sayıdan (2.147.483.647) bir fazla. Terminalde code: '22003' göreceksin; bu kod pgHatasiniCevir içinde yok, o yüzden 500'e düşüyor.

Çözümü göster
app.js
app.get("/esyalar/:id", async (req, res) => {    const { rows } = await pool.query(        "SELECT id, ad, fiyat, tur FROM esyalar WHERE id = $1",        [req.params.id]    )    if (rows.length === 0) {        return res.status(404).json({ hata: "Eşya bulunamadı" })    }    res.json(rows[0])}) app.post("/esyalar", async (req, res) => {    const { ad, fiyat, tur } = req.body    const { rows } = await pool.query(        "INSERT INTO esyalar (ad, fiyat, tur) VALUES ($1, $2, $3) RETURNING id, ad, fiyat, tur",        [ad, fiyat, tur]    )    res.status(201).json(rows[0])})

hatalar.js'de 400'e çevrilen kodlara 22003'ü ekle:

hatalar.js
    if (hata.code === "22P02" || hata.code === "22003" || hata.code === "23502" || hata.code === "23514") {

zirh.json dosyasına { "ad": "Kumaş Zırh", "fiyat": 300, "tur": "savunma" }, eksi-fiyat.json dosyasına { "ad": "Bedava Kılıç", "fiyat": -1, "tur": "saldırı" } yazarak denediğimizde:

İkinci terminal
PS C:\kod\pg-api> curl.exe http://localhost:3000/esyalar/1{"id":1,"ad":"Doran Kılıcı","fiyat":450,"tur":"saldırı"}PS C:\kod\pg-api> curl.exe http://localhost:3000/esyalar/99{"hata":"Eşya bulunamadı"}PS C:\kod\pg-api> curl.exe http://localhost:3000/esyalar/abc{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\pg-api> curl.exe http://localhost:3000/esyalar/1.5{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\pg-api> curl.exe http://localhost:3000/esyalar/2147483648{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\pg-api> curl.exe --json "@zirh.json" http://localhost:3000/esyalar{"id":7,"ad":"Kumaş Zırh","fiyat":300,"tur":"savunma"}PS C:\kod\pg-api> curl.exe --json "@zirh.json" http://localhost:3000/esyalar{"hata":"Bu kayıt zaten var"}PS C:\kod\pg-api> curl.exe --json "@eksi-fiyat.json" http://localhost:3000/esyalar{"hata":"Gönderilen değerlerden biri geçersiz"}

Düzeltmeden önce 2147483648 isteği {"hata":"Sunucuda bir hata oluştu"} döndürüyordu ve terminalde value "2147483648" is out of range for type integer yazıyordu. /esyalar/0 ve /esyalar/-1 404 verir: geçerli sayılar, ama öyle bir eşya yok. fiyat'ı hiç göndermezsen 23502, 0 gönderirsen eşya eklenir: CHECK (fiyat >= 0) sıfıra izin veriyor.

Görev 4

Arama ve güvenli sıralama

GET /esyalar route'unu yaz. İki isteğe bağlı sorgu parametresi alsın:

  • ara: adında bu metin geçen eşyalar (büyük-küçük harf fark etmeden). Yoksa hepsi.
  • sirala: yalnız ad ya da fiyat olabilir; yoksa ad. Başka her değerde 400 ve {"hata":"sirala yalnız ad ya da fiyat olabilir"}.

Aynı değerde iki eşya varsa aralarındaki sırayı id belirlesin. Beklenen yanıtlar:

İkinci terminal
PS C:\kod\pg-api> curl.exe "http://localhost:3000/esyalar?ara=doran"[{"id":2,"ad":"Doran Kalkanı","fiyat":450},{"id":1,"ad":"Doran Kılıcı","fiyat":450}]PS C:\kod\pg-api> curl.exe "http://localhost:3000/esyalar?ara=doran&sirala=fiyat"[{"id":1,"ad":"Doran Kılıcı","fiyat":450},{"id":2,"ad":"Doran Kalkanı","fiyat":450}]PS C:\kod\pg-api> curl.exe "http://localhost:3000/esyalar?sirala=fiyat;DROP%20TABLE%20esyalar"{"hata":"sirala yalnız ad ya da fiyat olabilir"}
İpucu 1

Arama için Ders 34'teki ILIKE ve % joker karakteri: WHERE ad ILIKE $1, değer `%${ara}%`. Burada şablon metin serbest, çünkü sonuç SQL'e değil $1'in değerine gidiyor.

İpucu 2

Önce ORDER BY $1 yazıp [req.query.sirala] göndermeyi dene. Hata almazsın ama sıralama da olmaz: Postgres $1'i bir sütun adı değil, bütün satırlarda aynı olan bir değer olarak görür. Sütun adı parametre olamaz.

İpucu 3

Sütun adını kendin seç: ["ad", "fiyat"].includes(sirala) doğruysa onu SQL metnine koymak güvenlidir, çünkü metne yalnız senin yazdığın iki sözcükten biri girebilir. Değilse 400 döndür. Sıralama ORDER BY ${sirala}, id olsun.

Çözümü göster
app.js
app.get("/esyalar", async (req, res) => {    const sirala = req.query.sirala ?? "ad"    if (!["ad", "fiyat"].includes(sirala)) {        return res.status(400).json({ hata: "sirala yalnız ad ya da fiyat olabilir" })    }    const ara = req.query.ara ?? ""    const { rows } = await pool.query(        `SELECT id, ad, fiyat FROM esyalar WHERE ad ILIKE $1 ORDER BY ${sirala}, id`,        [`%${ara}%`]    )    res.json(rows)})

Bu route /esyalar/:id'den ayrı bir adres olduğu için sırası önemli değil.

Sınır denemeleri: ?sirala=FIYAT da 400 verir; izin listesi büyük-küçük harfe duyarlı. ?ara= (boş) bütün eşyaları getirir, ?ara=xyz boş dizi döndürür. Kullanıcı % yazarsa o da joker karakter olarak çalışır; bu bir güvenlik açığı değil, yalnızca daha geniş bir arama. ?ara=kılıç Uzun Kılıç'ı bulur, ama ?ara=KILIÇ bulmaz: Postgres büyük I'yı Türkçe kurala göre ı'ya değil i'ye çevirir (Ders 34'teki dil ayarı notu).

Neden bir obje değil de dizi kullandık? { ad: "ad", fiyat: "fiyat" }[sirala] gibi bir obje kullansaydın ?sirala=constructor isteği, her objede kalıtımla bulunan constructor alanını bulurdu. SQL'e bir fonksiyonun metni girer, Postgres syntax error at or near "Object" der ve istemci 500 alırdı. Dizideki includes yalnız o iki sözcüğü tanır.

Görev 5

Stok göçü

Eşyalara stok bilgisi ekle: goclar klasörüne 004_esya_stok.sql adında bir göç yaz. Göç, esyalar tablosuna varsayılanı 10 olan, boş bırakılamayan ve eksiye düşemeyen bir stok sütunu eklesin. goc.js ile uygula, sonra betiği bir kez daha çalıştırıp hiçbir şeyin olmadığını gör. Beklenen çıktı:

Metin
Uygulandı: 004_esya_stok.sqlGöçler tamam.

Son olarak dosyanın adını 004_stok.sql yapıp betiği yeniden çalıştır. Ne oldu, neden? Sonra adı geri düzelt.

İpucu 1

Kalıp 001_oyuncu_seviye.sql'deki gibi: ALTER TABLE ... ADD COLUMN ... integer NOT NULL DEFAULT ... CHECK (...).

İpucu 2

DEFAULT 10 sayesinde var olan bütün eşyalar (Görev 3'te eklediğin Kumaş Zırh dahil) stok değeri alır; NOT NULL sütunu boş bırakılamaz. Sonucu bir sorguyla denetle: SELECT ad, stok FROM esyalar ORDER BY id LIMIT 3.

Çözümü göster
goclar/004_esya_stok.sql
ALTER TABLE esyalar ADD COLUMN stok integer NOT NULL DEFAULT 10 CHECK (stok >= 0);
Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env goc.jsUygulandı: 004_esya_stok.sqlGöçler tamam.PS C:\kod\pg-api> node --env-file=.env goc.jsGöçler tamam.

SELECT ad, stok FROM esyalar ORDER BY id LIMIT 3 ile denetlediğimizde ilk üç eşyanın stoğu 10 çıktı. Dosyanın adı 004_stok.sql olunca betik onu tanımadı: goc_gecmisi'nde bu ad yok. Dosyayı yeni bir göç sanıp yeniden çalıştırdı ve Postgres reddetti:

Terminal — PowerShell
PS C:\kod\pg-api> node --env-file=.env goc.js...error: column "stok" of relation "esyalar" already exists

Göçleri birbirinden ayıran şey adlarıdır; uygulanmış bir dosyanın adı da içeriği gibi dokunulmazdır. Adı geri düzeltince betik yine Göçler tamam. der.

Kendini sına

Soru 1

Hangisi SQL injection'a karşı güvenli?

JavaScript
pool.query("SELECT id, ad FROM oyuncular WHERE ad = '" + ad + "'")pool.query(`SELECT id, ad FROM oyuncular WHERE ad = '${ad}'`)pool.query("SELECT id, ad FROM oyuncular WHERE ad = $1", [ad])
Cevabı göster

Yalnız üçüncüsü. İlk ikisi aynı şeyi iki farklı yazımla yapıyor: kullanıcının yazdığını SQL metninin içine yapıştırıyor; değer komutun parçası olabiliyor. Üçüncüde metin ile değer ayrı gidiyor ve Postgres metni değerden önce okuyor.

Soru 2

Bir istemci /istatistik yanıtındaki mac değerine 1 ekledi ve "31" elde etti. Sebep ne, düzeltmenin en temiz yeri neresi?

Cevabı göster

count sonucu bigint'tir ve pg onu metin olarak verir ("3"); metne 1 eklemek onları yan yana yapıştırır. En temiz düzeltme SQL'de: count(m.id)::int. Böylece sayı daha veritabanından çıkarken integer olur, JSON'a da tırnaksız yazılır.

Soru 3

Bu kod neden güvenilir bir transaction değil?

JavaScript
await pool.query("BEGIN")await pool.query("UPDATE oyuncular SET seviye = seviye + 1 WHERE id = $1", [id])await pool.query("COMMIT")
Cevabı göster

Her pool.query havuzdan boştaki herhangi bir bağlantıyı alır. BEGIN bir bağlantıda, UPDATE başka bir bağlantıda çalışabilir; o zaman UPDATE transaction'ın dışında kalır. Transaction için const client = await pool.connect() ile tek bir bağlantı alınır, üç sorgu da client.query ile çalıştırılır ve bağlantı finally'de client.release() ile geri verilir.

Soru 4

Hangi HTTP durum kodunu döndürürdün? (a) 23505, (b) POST /maclar sırasında 23503, (c) DELETE /oyuncular/1 sırasında 23503, (d) 22P02

Cevabı göster

(a) 409 Conflict: var olan bir kayıtla çakışma. (b) 400 Bad Request: istemci olmayan bir oyuncuyu gösterdi. (c) 409 Conflict: istek düzgün, ama oyuncunun maçları silinmesini engelliyor. (d) 400 Bad Request: değer sütunun tipine uymuyor. Dördünde de hatayı yapan istemci olduğu için 500 yanlış olurdu.

Soru 5

API'n ilk on istekte çalışıyor, on birincide donuyor; terminalde hata yok. İlk neye bakarsın?

Cevabı göster

Geri verilmemiş bağlantılara. Varsayılan havuzda 10 bağlantı var; pool.connect() ile alınıp client.release() ile geri verilmeyen her bağlantı havuzdan eksilir. On birinci istek boş bağlantı bekler ve connectionTimeoutMillis yoksa sonsuza kadar bekler. pool.connect() geçen satırları bul: ya pool.query'ye çevir ya da release'i finally'ye yaz.

Soru 6

Arda'yı ekledin, 6 numarayı aldı. Sonra Deniz'i yeniden eklemeyi denedin (409) ve bölgesi olmayan bir oyuncu gönderdin (400). Ardından Zeynep'i ekledin. Zeynep kaç numarayı aldı?

Cevabı göster

9. GENERATED ALWAYS AS IDENTITY her INSERT denemesinde sıradaki numarayı ayırır; satır sonradan bir kısıta takılsa bile o numara geri verilmez. Başarısız iki deneme 7'yi ve 8'i harcadı. Bu bir hata değil: id'nin işi benzersiz olmak, aralıksız olmak değil. Kimlik numaralarında boşluk görünce telaşlanma.

Soru 7

Projende tablolar elle SQL ile duruyor ve her şey çalışıyor. Bir ORM'e geçmeyi ne zaman düşünürsün, geçince neyi yine bilmen gerekir?

Cevabı göster

TypeScript'e geçip tabloların tiplerinin koda kendiliğinden gelmesini istediğinde, göç dosyalarını elle yazmak yorucu hâle geldiğinde ya da ekip ortak bir yapı istediğinde. Geçince de SQL'i bilmen gerekir: ORM'in ürettiği sorguyu okumak, yavaş bir sorguyu düzeltmek ve "ham SQL" kapılarını kullanırken parametreleri unutmamak için.

Kayıt noktası

pg-api yeni bir proje, bu yüzden Git'i burada yeniden başlatıyorsun. Kaydetmeden önce git status ile listeye bak: .env ve node_modules orada olmamalı.

Bir sonraki ders Blok 5'in projesi: takım API'sini JSON dosyasından PostgreSQL'e taşıyacak, ilişkili tablolar, sayfalama, arama ve transaction gerektiren bir işlem ekleyip yayına alacaksın.

İşaret yalnızca bu tarayıcıda saklanır, kimseye gönderilmez.