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
pgpaketiyle bir bağlantı havuzu (Pool) kurup Node.js'ten PostgreSQL'e sorgu gönderebilirsin- Bağlantı dizesini
.envdosyasında tutabilir, parçalarını vesslmodeayarını açıklayabilirsin - Kullanıcıdan gelen her değeri
$1,$2parametreleriyle 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
- Üç program, tek istek
- Proje klasörü ve pg paketi
- Veritabanını hazırla: schema.sql
- Bağlantı dizesi ve .env
- İlk sorgu: Pool ve query
- Satırlar JSON olur
- Havuz: bağlantıları paylaşmak
- SQL injection: kullanıcının yazdığı metin
- Parametreli sorgu: $1, $2
- Yazmak ve hata kodlarını çevirmek
- Şema dosyası ve göç (migration)
- Neon'un sunucusuz sürücüsü
- ORM'leri tanımak
- Görevler
- Kendini sına
- 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 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.
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 vulnerabilitiesKlasö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:
{ "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.envdosyasındaki değişkenleriprocess.env'e yükler (Ders 29).--watch: dosyayı her kaydettiğinde programı yeniden başlatır (Ders 27).devgeliştirirken,startcanlı 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:
node_modules.envVeritabanı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.
- Neon'da projeni aç, SQL Editor'de
CREATE DATABASE pgapi;yaz ve Run'a bas. - SQL Editor'ün üstündeki veritabanı listesinden
pgapi'yi seç. Arayüz değişmiş olabilir; listeyi göremezsen sayfayı yenile. - VS Code'da
schema.sqladında bir dosya aç, aşağıdaki iki tabloyu yaz ve kaydet. İçeriği kopyalayıp SQL Editor'de çalıştır. - Ardından örnek veriyi aynı yoldan ekle; onu da
veri.sqladıyla projene kaydet.
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.
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):
postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/pgapi?sslmode=require&channel_binding=requireDizeyi koda yazmak yerine .env dosyasına koy (Ders 29). Proje klasöründe .env adında bir dosya aç ve tek satır yaz:
DATABASE_URL=postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/pgapi?sslmode=verify-full&channel_binding=requireBuraya 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:
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ç:
import { Pool } from "pg" export const pool = new Pool({ connectionString: process.env.DATABASE_URL})import { Pool } from "pg":pgpaketindenPoolsı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:
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()PS C:\kod\pg-api> node --env-file=.env deneme.js[ { mesaj: 'Merhaba Postgres', toplam: 5 } ]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;awaitsonucu bekler (Ders 15).sonuc.rows: gelen satırlar, her biri bir obje. Sütun adları objenin anahtarları oldu:AS mesajyazdığın için anahtarmesaj.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 betiklerdepool.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:
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()[ { id: 1, ad: 'Deniz', bolge: 'TR' }, { id: 2, ad: 'Ece', bolge: 'TR' }, { id: 4, ad: 'Selin', bolge: 'TR' }]3SELECT[ 'id', 'ad', 'bolge' ]| Alan | Ne tutar |
|---|---|
rows | Satırlar: her satır bir obje, sütun adları anahtar |
rowCount | Kaç satır geldi; UPDATE ve DELETE'te kaç satır değişti |
command | Çalışan komutun adı: SELECT, INSERT, DELETE… |
fields | Sü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).
TypeError: 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).
import { pool } from "./db.js" const sonuc = pool.query("SELECT id, ad FROM oyuncular")console.log(sonuc.rows.length)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.
AggregateError [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).
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.0Hata 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.
password 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.
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.
getaddrinfo ENOTFOUNDNe 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ış:
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.
The server does not support SSL connectionsNe 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.
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.0Nası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ç:
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:
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:3000PS 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:
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()[ { 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 tipi | Node'a gelen | JSON'da |
|---|---|---|
integer | sayı: 3 | 3 |
text | metin: 'Deniz' | "Deniz" |
boolean | true / false | true / false |
timestamptz | Date objesi | "2026-10-11T17:02:39.352Z" |
bigint (count sonucu) | metin: '7' | "7" |
numeric (avg, round sonucu) | metin: '6.4285714285714286' | "6.4285714285714286" |
NULL | null | null |
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.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)})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.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)})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"sonucuintegeryap" demek. Bir oyuncunun maç sayısıinteger'a rahatça sığar.::floatondalı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 yerdenumeric'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,avgboş grubaNULLverir. Ona0demek yanlış olurdu;null"bilinmiyor" demek.
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.
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()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.
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()ileclient.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 veCOMMITaynı 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.
remaining connection slots are reserved for roles with the SUPERUSER attributeNe 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.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:
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 500error: 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.
client.release() unutulduNe 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.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)})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.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:
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:
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:
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:20Mesaj "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.
Unhandled 'error' event ve terminating connection due to administrator commandNe 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.
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)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.0Nasıl düzelir? pool.on("error", ...) ile dinleyici ekle. Aynı deneme, dinleyiciyle şöyle biter:
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ışıyorHavuz 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.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:
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"}]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:
SELECT id, ad, bolge FROM oyuncular WHERE ad = 'x' OR '1'='1';| id | ad | bolge |
|---|---|---|
| 1 | Deniz | TR |
| 2 | Ece | TR |
| 3 | Mert | EUW |
| 4 | Selin | TR |
| 5 | Kaan | EUW |
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:
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}]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.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)})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ş.
Dört kural:
- Numaralar diziyle eşleşir:
$1dizinin ilk elemanı,$2ikincisi. 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ı,
ASCya daDESCgibi 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.
pool.query(`SELECT id, ad FROM oyuncular WHERE ad = '${ad}'`)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.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])})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.
bind message supplies 1 parameters, but prepared statement "" requires 0Ne 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.
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()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.
invalid 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.
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: "abc"<br> 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.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:
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 "oyuncular_ad_key"<br> 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ı:
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ı:
| Kod | Ne oldu | HTTP |
|---|---|---|
23505 | UNIQUE sütuna var olan bir değer yazıldı | 409 Conflict |
23503 (ekleme) | olmayan bir kayda bağlanılmak istendi | 400 Bad Request |
23503 (silme) | bağlı kayıtları olan bir satır silinmek istendi | 409 Conflict |
23502 | zorunlu (NOT NULL) sütun boş geldi | 400 Bad Request |
23514 | CHECK kuralına uymayan değer geldi | 400 Bad Request |
22P02 | değ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ç:
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")){ 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' }nullnode 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:
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.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ı.0ise öyle bir oyuncu yok:404. Silme başarılıysa gövdesiz204 No Content(Ders 26).- Dört parametreli
app.use, Express'in hata yakalayıcısıdır (Ders 28). Express 5'te birasyncroute'un içinden fırlayan hata,try/catchyazmana 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övdeyi400durumlu bir hatayla bildirir; onu olduğu gibi geçiriyoruz.- Geri kalan her şey gerçekten sunucunun sorunudur: ayrıntıyı
console.errorile 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:
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:
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:
CREATE TABLE oyuncular ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ad text NOT NULL UNIQUE, bolge text NOT NULL);ERROR: relation "oyuncular" already existsTabloyu 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):
ALTER TABLE oyuncular ADD COLUMN seviye integer NOT NULL DEFAULT 1 CHECK (seviye >= 1);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:
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_gecmisitablosunu 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
.sqldosyaları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 havuzBEGIN'i bir bağlantıya, sonraki sorguyu başka bir bağlantıya verebilirdi. Bağlantıfinally'de geri verilir. - 29. satır:
throw hatabetiği durdurur. Bozuk bir göçten sonra sıradakileri çalıştırmak, yamaları atlayarak yüklemek gibidir.
goc.js yalnız tabloda olmayan dosyayı çalıştırır, sonra adını tabloya ekler.Betiği iki kez çalıştır:
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:
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:
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:
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.sqlilk kurulumun hikâyesi olarak kalır; bundan sonraki her değişiklikgoclarklasö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.
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:
{ 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:
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:
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:
{ 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:
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 SQL | Drizzle | Prisma | |
|---|---|---|---|
| Sorguyu nasıl yazarsın | SQL metni | SQL'e benzeyen JavaScript | Kendi sorgu yazımı |
| Göç dosyaları | Elle (bu dersteki gibi) | drizzle-kit üretir | Prisma Migrate üretir |
| TypeScript tipleri | Kendin yazarsın | Tablodan gelir | Şemadan gelir |
| Çalışan SQL | Senin yazdığın | Yazdığına çok yakın | Arka 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:
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ç.
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ı:
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
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()Can İksiri: 50 altınUzun Kılıç: 350 altınSı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.
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ı:
{ 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
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(){ 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.
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; yoksa404ve{"hata":"Eşya bulunamadı"}.POST /esyalar: gövdedekiad,fiyatveturile eşya eklesin,201ile 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.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:
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:
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.
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ızadya dafiyatolabilir; yoksaad. Başka her değerde400ve{"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:
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.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.
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ı:
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
ALTER TABLE esyalar ADD COLUMN stok integer NOT NULL DEFAULT 10 CHECK (stok >= 0);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:
PS C:\kod\pg-api> node --env-file=.env goc.js...error: column "stok" of relation "esyalar" already existsGöç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?
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?
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.