Blok 5 · Ders 38
Blok 5 projesi: veritabanlı API
Takım API'sini JSON dosyasından PostgreSQL'e taşıyor, ilişkili tablolar, sayfalama ve arama ekleyip yayına alıyorsun.
Bu dersin sonunda
- İç içe bir JSON dosyasını tablolara, sütunlara ve ilişkilere ayıran bir şema tasarlayabilir, onu
schema.sqlolarak yazabilirsin - Eski veriyi temizleyerek tek bir transaction içinde PostgreSQL'e taşıyan, ikinci kez çalıştırılınca bozulmayan bir betik yazabilirsin
- Express uç noktalarını parametreli SQL sorgularına çevirip API'nin dışarıya verdiği JSON'u koruyabilirsin
- Sayfalamayı
LIMIT/OFFSETile, aramayıILIKEile veritabanına taşıyıp kullanıcının yazdığı joker karakterleri etkisiz bırakabilirsin - Birden çok tabloya yazan bir isteği Node'dan transaction ile yapabilir, yeni karşılaştığın Postgres hata kodlarını doğru HTTP durum kodlarına çevirebilirsin
- API'ni
DATABASE_URLile Neon'daki canlı veritabanına bağlayıp Render'da yayına alabilirsin
Bu sayfada
- Plan: dışarıdan aynı, içeriden yeni
- Başlangıç: veri.json
- Şema tasarımı
- Ayrı bir Neon projesi ve geliştirme dalı
- Veriyi taşımak: tasi.js
- Kimlik sayacı: taşımanın gizli tuzağı
- Uç noktaları SQL'e çevirmek
- Veritabanı hatalarını HTTP'ye çevirmek
- Sayfalama: LIMIT ve OFFSET
- Arama: ILIKE ve kullanıcının yazdığı joker karakterler
- Takımı tek seferde kurmak: transaction
- Yayına almak
- Görevler
- Kendini sına
- Kayıt noktası
Blok 4'ün sonunda takım API'n internetteydi ama verisini bir JSON dosyasında tutuyordu. Blok 5'te veritabanını öğrendin: tablolar, sorgular, ilişkiler, transaction ve Node'dan bağlanmak. Bu derste ikisini birleştiriyorsun: aynı API, aynı adresler, aynı JSON yanıtlar; arkasında ise PostgreSQL. Dersin sonunda dört ilişkili tablon, eski veriyi taşıyan bir betiğin, sayfalamayı ve aramayı veritabanına yaptıran bir listen, takımı tek seferde kuran bir uç noktan ve Render'da kalıcı verisiyle çalışan bir API'n olacak.
Plan: dışarıdan aynı, içeriden yeni
Takım API'ni kullanan programlar var: Ders 25'teki gibi bir web sayfası, bir arkadaşının Discord botu, belki bir telefon uygulaması. Hepsi senin adreslerine istek atıyor ve gelen JSON'un belli bir biçimde olmasına güveniyor. Veriyi dosyadan veritabanına taşırken onları bozmamak gerekiyor. Arka plandaki değişiklik dışarıdan fark edilmemeli; fark edilecekse de bilerek ve duyurarak.
| Ders 32'de | Bu dersin sonunda | |
|---|---|---|
| Veri nerede | veri.json; Render'da yeniden başlayınca kaybolur | PostgreSQL (Neon); kalıcı |
GET /api/takimlar | ara, sayfa, boyut; takımlar tam hâliyle, toplam X-Toplam-Sayi başlığında | aynı parametreler, aynı başlık; takımlar özet bilgiyle |
GET /api/takimlar/:id | tek takım | aynı JSON |
POST /api/takimlar | takımı dosyaya ekler | takımı, oyuncularını ve şampiyonlarını tek transaction'da kurar |
PATCH, DELETE | dosyayı değiştirir | aynı adresler, aynı yanıtlar |
| Şampiyon uç noktaları | ad değişince bütün takımları dolaşır | ad tek bir satırda değişir |
| Hatalar | 400, 404, 500, 503 | ayrıca 409; veritabanı hataları doğru koda çevrilir |
Birkaç şeyi bilerek değiştireceğiz: listedeki takımlar özet olarak gelecek, aynı kullanıcı adı iki takımda olamayacak, kullanılan bir şampiyonu silmek 409 alacak. Her birinin sebebini yeri gelince göreceksin ve README'ye yazacaksın. Projenin yapılacaklar listesi:
- Şema: dört tablo,
schema.sqlve onu çalıştırankur.js. - Taşıma:
veri.json'daki takımları temizleyerek tablolara aktarantasi.js. - Uç noktalar: her uç nokta dosya yerine SQL ile çalışacak, JSON biçimi korunacak.
- Hata çevirisi: Ders 37'deki çeviriyi getirip bu projede çıkan yeni kodları ekleyeceksin.
- Liste: sayfalama ve arama dosyadaki dizide değil, veritabanında yapılacak.
- Takım kurmak: takım, oyuncular ve şampiyonlar tek transaction'da.
- Yayına alma: Neon'da canlı veritabanı, Render'da
DATABASE_URL.
Dersin sonunda klasörün şöyle görünecek:
takim-api/├── .env PORT ve DATABASE_URL (Git'e girmez)├── .env.canli canlı veritabanının dizesi (Git'e girmez)├── .gitignore├── package.json├── package-lock.json├── README.md├── veri.json eski veri: taşımanın kaynağı├── schema.sql tabloların tanımı├── db.js bağlantı havuzu (Ders 37)├── kur.js schema.sql'i çalıştırır├── tasi.js veri.json'ı tablolara taşır├── dogrula.js gelen veriyi denetler (Ders 32)├── hatalar.js Postgres hatalarını HTTP'ye çevirir (Ders 37)├── sunucu.js Express uygulaması└── istekler/ curl.exe ile göndereceğin JSON gövdeleriBaşlangıç: veri.json
Ders 32'nin sonunda denemelerden sonra veri.json'u git restore ile ilk hâline döndürmüştün: altı takım, hiç silinmiş kayıt yok, her şey kurallara uygun. Gerçek bir API'nin dosyası öyle kalmaz. Taşıma betiğini gerçekçi bir dosyayla denemek için veri.json'un içeriğini aşağıdakiyle değiştir. İlk altı takım seninkiyle aynı, numaraları da.
{ "sonId": 10, "sampiyonlar": [ "Ahri", "Annie", "Brand", "Garen", "Jinx", "Lee Sin", "Lux", "Morgana", "Sona", "Teemo", "Thresh", "Vi", "Yasuo", "Zed" ], "takimlar": [ { "id": 1, "ad": "Gece Kurtları", "olusturulma": "2026-09-01T18:30:00.000Z", "oyuncular": [ { "kullaniciAdi": "deniz", "rol": "orta" }, { "kullaniciAdi": "ece", "rol": "destek" } ], "sampiyonlar": ["Ahri", "Lux", "Garen"] }, { "id": 2, "ad": "Mavi Ejderler", "olusturulma": "2026-09-02T09:15:00.000Z", "oyuncular": [ { "kullaniciAdi": "mert", "rol": "alt" }, { "kullaniciAdi": "selin", "rol": "orman" } ], "sampiyonlar": ["Jinx", "Thresh", "Lux"] }, { "id": 3, "ad": "Baron Avcıları", "olusturulma": "2026-09-03T20:00:00.000Z", "oyuncular": [ { "kullaniciAdi": "kaan", "rol": "orman" }, { "kullaniciAdi": "zeynep", "rol": "üst" } ], "sampiyonlar": ["Yasuo", "Lee Sin"] }, { "id": 4, "ad": "Kuzey Rüzgarı", "olusturulma": "2026-09-05T11:45:00.000Z", "oyuncular": [ { "kullaniciAdi": "arda", "rol": "üst" }, { "kullaniciAdi": "ipek", "rol": "orta" } ], "sampiyonlar": ["Annie", "Teemo"] }, { "id": 5, "ad": "Ateş Kuşları", "olusturulma": "2026-09-09T19:40:00.000Z", "oyuncular": [ { "kullaniciAdi": "cem", "rol": "orta" }, { "kullaniciAdi": "elif", "rol": "destek" } ], "sampiyonlar": ["Annie", "Brand"] }, { "id": 6, "ad": "Kurt Sürüsü", "olusturulma": "2026-09-12T21:10:00.000Z", "oyuncular": [ { "kullaniciAdi": "ozan", "rol": "üst" }, { "kullaniciAdi": "irem", "rol": "orman" } ], "sampiyonlar": ["Teemo", "Yasuo"] }, { "id": 8, "ad": "Kristal Kalkan", "olusturulma": "2026-09-20T16:20:00.000Z", "oyuncular": [ { "kullaniciAdi": "burak", "rol": "destek" }, { "kullaniciAdi": "defne", "rol": "alt" } ], "sampiyonlar": ["lux ", "Garen"] }, { "id": 9, "ad": "Son Vuruş", "olusturulma": "2026-09-24T13:05:00.000Z", "oyuncular": [ { "kullaniciAdi": "emre", "rol": "alt" } ], "sampiyonlar": ["Jinx", "Ahri", "Jinx"] }, { "id": 10, "ad": "Gölge Takımı", "olusturulma": "2026-10-01T08:00:00.000Z", "oyuncular": [], "sampiyonlar": [] } ]}Bu, API'nin birkaç hafta yayında kaldıktan sonraki dosyası gibi: üç takım daha kurulmuş, 7 numaralı takım bir ara silinmiş, sonId sıradaki takıma 11 verecek. Bir de iki küçük kusur var; dosyayı elle düzenleyen biri gözden kaçırmış olsun. dogrula.js yalnız API'ye gelen istekleri denetliyordu; dosyayı elle değiştiren biri onun kurallarının hiçbirine takılmaz. Kusurları birazdan taşıma betiği bulacak. Ders 32'deki API bu dosyayı olduğu gibi gösteriyor:
PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/8{"id":8,"ad":"Kristal Kalkan","olusturulma":"2026-09-20T16:20:00.000Z","oyuncular":[{"kullaniciAdi":"burak","rol":"destek"},{"kullaniciAdi":"defne","rol":"alt"}],"sampiyonlar":["lux ","Garen"]}Yeni API de tek takımı bu biçimde, aynı alan adlarıyla döndürecek.
Şema tasarımı
JSON'da bir takım her şeyi içinde taşıyor: adını, oyuncularını, şampiyonlarını. Bir tabloda ise her hücre tek bir değer tutar; "oyuncular" diye bir hücreye bir liste sığmaz. Tasarımın ilk işi bu yüzden iç içe yapıyı açmak: hangi şeylerden söz ediyoruz, her biri nerede duracak, birbirlerine nasıl bağlanacaklar?
Adım adım gidelim:
- Şeyleri bul. Dosyada üç tür şey var: takım, oyuncu, şampiyon. Her biri bir tablo olacak:
takimlar,oyuncular,sampiyonlar. - Listeleri dışarı çıkar. Takımın
oyuncularlistesi ayrı bir tabloya gidiyor; her oyuncu satırı hangi takıma ait olduğunutakim_idile söylüyor. Bir oyuncu tek takımda, bir takımda çok oyuncu: bire-çok ilişki. - Çoka-çoğu ara tabloyla kur. Bir takımda çok şampiyon, bir şampiyon çok takımda: ara tablo
takim_sampiyonlari. Şampiyon adı yerine numarası yazılıyor; ad yalnızsampiyonlartablosunda, tek bir yerde duruyor. - Adları çevir. JSON'daki
kullaniciAdi, tabloda snake_case kuralıylakullanici_adioluyor. JSON'a geri dönerken takma adla camelCase'e çevireceksin; Ders 37'deki çift tırnak kuralı burada da geçerli.
takimlar'a, oyuncular listesi oyuncular'a gidiyor; şampiyonlar adlarıyla değil numaralarıyla bağlanıyor.Her tablonun sütunlarını ve aralarındaki bağları bir arada çizelim. Ders 36'daki şemaya benziyor, ama bu kez tablolar bu projenin:
1 ucu birincil anahtarın, N ucu yabancı anahtarın olduğu taraf; çizgilerin yanında, 1 tarafındaki satır silinince ne olacağı yazıyor.Kararları tek tek yazalım. Her satırın arkasında bir soru var; aynı soruları ileride kendi projelerinde de soracaksın:
| Soru | Karar | Neden |
|---|---|---|
| Takımın numarası kim versin? | GENERATED ALWAYS AS IDENTITY, ama taşırken eski numaralar korunacak | /api/takimlar/8 adresi taşımadan sonra da aynı takımı göstermeli |
| Oyuncu takımın neresinde? | oyuncular.takim_id, ON DELETE CASCADE | Bu API'de oyuncu takımın parçası; takım silinince kaydı da gitmeli. JSON'da da öyleydi. |
| Takım ile şampiyon? | ara tablo, bileşik birincil anahtar | Çoka-çok; aynı takım–şampiyon çifti iki kez yazılamaz |
| Şampiyon silinmek istenirse? | ON DELETE RESTRICT | Bir takımda kullanılan şampiyon silinemesin; Ders 32'deki "kullanılıyor, silinemez" kuralı artık veritabanında |
| Aynı kullanıcı adı iki kez? | UNIQUE | Bir oyuncu iki takımda olamaz. Ders 32'de bu kural yoktu; yeni bir kural. |
| Takım adı kuralı? | CHECK ile 2–40 karakter | dogrula.js'teki adHatasi'nın kuralı veritabanında da duruyor |
| Rol ne olabilir? | CHECK (rol IN (...)) | Yalnız beş koridor, dogrula.js'teki listeyle aynı |
Proje klasöründe schema.sql adında bir dosya aç ve şemayı yaz:
-- Takım API'sinin şeması. DİKKAT: ilk dört satır tabloları siler.DROP TABLE IF EXISTS takim_sampiyonlari;DROP TABLE IF EXISTS oyuncular;DROP TABLE IF EXISTS sampiyonlar;DROP TABLE IF EXISTS takimlar; CREATE TABLE takimlar ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ad text NOT NULL CHECK (char_length(ad) BETWEEN 2 AND 40), olusturulma timestamptz NOT NULL DEFAULT now()); CREATE TABLE oyuncular ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, kullanici_adi text NOT NULL UNIQUE, rol text NOT NULL CHECK (rol IN ('üst', 'orman', 'orta', 'alt', 'destek')), takim_id integer NOT NULL REFERENCES takimlar (id) ON DELETE CASCADE); CREATE TABLE sampiyonlar ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ad text NOT NULL UNIQUE); CREATE TABLE takim_sampiyonlari ( takim_id integer NOT NULL REFERENCES takimlar (id) ON DELETE CASCADE, sampiyon_id integer NOT NULL REFERENCES sampiyonlar (id) ON DELETE RESTRICT, PRIMARY KEY (takim_id, sampiyon_id)); CREATE INDEX oyuncular_takim_id_idx ON oyuncular (takim_id);CREATE INDEX takim_sampiyonlari_sampiyon_id_idx ON takim_sampiyonlari (sampiyon_id);Dosyanın her parçasını önceki derslerden tanıyorsun. Üç noktaya dikkat:
- Silme sırası:
DROPsatırları, başka tabloyu gösteren tablolardan başlıyor. Ters sıradatakimlar'ı önce silmeye kalksaydın Ders 36'dakicannot drop table ... because other objects depend on ithatasını alırdın. char_length(ad)metnin karakter sayısını verir;BETWEEN 2 AND 40iki ucu da kapsar. Boş ve tek harfli adlar, kırk harften uzun adlar tabloya giremez.- İndeksler: Ders 36'da yabancı anahtara Postgres'in kendiliğinden indeks açmadığını görmüştün.
oyuncular.takim_idher takım ayrıntısında aranacak. Ara tablonun bileşik anahtarıtakim_idile başladığı için o sütundaki aramalar zaten hızlı;sampiyon_idile arama için ayrı bir indeks açtık. "Lux hangi takımlarda?" sorusu bu sütunla sorulur; bir şampiyon silinmek istendiğindeRESTRICTde "bu şampiyonu kullanan satır var mı?" diye ara tabloyu tam bu sütunda arar.
Ayrı bir Neon projesi ve geliştirme dalı
Tablolarını kuracağın yere karar ver. Ders 36'daki lig veritabanında takimlar, oyuncular, sampiyonlar ve takim_sampiyonlari adlı tablolar zaten duruyor; schema.sql'i oraya çalıştırırsan çakışırlar. Yayındaki bir API'nin verisi de ders denemeleriyle aynı yerde durmamalı. Bu yüzden takım API'sine Neon'da kendi projesini açıyorsun ve Ders 35'teki dalları kullanıyorsun: canlı veri projenin ana dalı production'da, geliştirme ise onun bir kopyasında. Neon'un ücretsiz planında 100 projeye kadar açabilirsin.
- Neon'da New Project'e bas. Ad olarak
takim-apiyaz, Postgres sürümü olarak 18'i, bölge olarak Ders 33'teki gibi AWS Europe (Frankfurt)'u seç ve Create project'e bas. Sürüm, ileride göreceğin bir hata kodunu değiştiriyor (Ders 36'dakiRESTRICTnotu). Render servisin de Frankfurt'ta; veritabanı ile sunucu aynı bölgede olunca her sorgu daha kısa yol gider. Ana dalın adıproduction; canlı API'nin verisi burada duracak. - Sol menüden Branches sayfasına gir ve New branch'e bas. Parent branch
productionkalsın, dalın adınagelistirmeyaz, veri olarak Current data'yı seç. - Automatically delete branch after kutusunun işaretini kaldır. Ders 35'te gördüğün gibi Neon, panelden açılan dalları varsayılan olarak bir gün sonra siler; geliştirme dalının bütün proje boyunca yaşaması gerekiyor. Create'e bas.
- Üstteki Connect düğmesine bas. Dal olarak
gelistirme'yi seç, Connection pooling anahtarını kapat ve bağlantı dizesini kopyala. Havuzlu bağlantıyı yayına alırken kullanacağız; tablo kurmak ve veri taşımak gibi yönetim işleri için Neon doğrudan bağlantıyı öneriyor (Ders 37).
.env dosyanda Ders 32'den iki satır var: PORT ve VERI_DOSYASI. VERI_DOSYASI artık kullanılmayacak; onu sil ve kopyaladığın dizeyi DATABASE_URL satırına yaz. Ders 37'deki gibi dizedeki sslmode=require'ı sslmode=verify-full yap; yoksa her başlatmada pg'nin uzun güvenlik uyarısını görürsün.
PORT=3000DATABASE_URL=postgresql://neondb_owner:SIFREN@ep-ornek-123456.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=requireDers 32'deki projede pg yoktu; kur:
PS C:\kod\takim-api> npm install pg added 14 packages, and audited 80 packages in 2s 28 packages are looking for funding run `npm fund` for details found 0 vulnerabilitiesBağlantı havuzunu Ders 37'deki gibi tek bir dosyada kur. C:\kod\pg-api\db.js'i bu projeye kopyala; içi şu olmalı:
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)})Şimdi schema.sql'i veritabanına gönderen küçük betik. Proje klasöründe kur.js:
import { readFile } from "node:fs/promises"import { pool } from "./db.js" const sema = await readFile("schema.sql", "utf8")await pool.query(sema)console.log("Şema kuruldu")await pool.end()Dosyanın tamamı tek bir pool.query ile gidiyor. Parametresiz bir sorguda pg birden çok komutu, aralarındaki noktalı virgüllerle birlikte gönderebilir. Sondaki pool.end() havuzu kapatır; yazmasaydın betik işini bitirdikten sonra boştaki bağlantı yüzünden 10 saniye kadar kapanmadan beklerdi. Komutları kısaltmak için package.json'daki scripts kısmına iki betik ekle:
"scripts": { "start": "node sunucu.js", "dev": "node --watch --env-file=.env sunucu.js", "kur": "node --env-file=.env kur.js", "tasi": "node --env-file=.env tasi.js" },start olduğu gibi kalıyor: Render'da .env yok, ayarları Render veriyor (Ders 32). --env-file yalnız kendi bilgisayarında çalıştırdığın betiklerde duruyor.
import { readFile } from "node:fs/promises"import { pool } from "./db.js" const sema = await readFile("schema.sql", "utf8")await pool.query(sema)console.log("Şema kuruldu")await pool.end()PS C:\kod\takim-api> npm run kur > takim-api@1.0.0 kur> node --env-file=.env kur.js Şema kuruldunpm run kur, .env'i yükleyip kur.js'i çalıştırıyor. Tablolar artık Neon'daki gelistirme dalında.
Neon'un SQL Editor'ünü aç, üstteki seçicide dal olarak gelistirme'yi seç ve tabloların gerçekten kurulduğuna bak. information_schema.tables, veritabanındaki tabloların listesini tutan hazır bir görünümdür:
SELECT table_nameFROM information_schema.tablesWHERE table_schema = 'public'ORDER BY table_name;| table_name |
|---|
| oyuncular |
| sampiyonlar |
| takim_sampiyonlari |
| takimlar |
Veriyi taşımak: tasi.js
Tablolar hazır ama boş. Şimdi veri.json'daki takımları, oyuncuları ve şampiyonları tablolara aktaran bir betik yazacaksın.
Betiğin planı:
veri.json'u oku.takimlartablosu doluysa dur: taşıma iki kez yapılmasın.- Bir transaction aç. Ya her şey taşınsın ya hiçbir şey.
- Şampiyonları ekle ve her birinin aldığı numarayı not et: ad →
id. - Her takımı eski numarasıyla ekle; ardından oyuncularını ve şampiyon bağlantılarını.
- Hata yoksa
COMMIT, varsaROLLBACK.
Üçüncü adımı Ders 37'deki goc.js'ten tanıyorsun: transaction'ın bütün komutları aynı bağlantıdan gitmeli. Havuz (pool) ise her pool.query'yi o an boşta olan herhangi bir bağlantıya verir. Bu yüzden pool.connect() ile bir bağlantı ayırıp işin sonuna kadar onu kullanacaksın.
pool.connect() ile bir bağlantıyı ayırır; BEGIN'den COMMIT'e kadar her komut o bağlantıdan gider, release() onu havuza geri bırakır.Proje klasöründe tasi.js adında bir dosya aç ve betiğin ilk hâlini yaz:
import { readFile } from "node:fs/promises"import { pool } from "./db.js" const veri = JSON.parse(await readFile("veri.json", "utf8"))const istemci = await pool.connect() try { const { rows } = await istemci.query("SELECT count(*)::int AS sayi FROM takimlar") if (rows[0].sayi > 0) { throw new Error("takimlar tablosu boş değil, taşıma daha önce yapılmış") } await istemci.query("BEGIN") const sampiyonIdleri = {} for (const ad of veri.sampiyonlar) { const sonuc = await istemci.query( "INSERT INTO sampiyonlar (ad) VALUES ($1) RETURNING id", [ad] ) sampiyonIdleri[ad] = sonuc.rows[0].id } let oyuncuSayisi = 0 let baglantiSayisi = 0 for (const takim of veri.takimlar) { await istemci.query( `INSERT INTO takimlar (id, ad, olusturulma) OVERRIDING SYSTEM VALUE VALUES ($1, $2, $3)`, [takim.id, takim.ad, takim.olusturulma] ) for (const oyuncu of takim.oyuncular) { await istemci.query( "INSERT INTO oyuncular (kullanici_adi, rol, takim_id) VALUES ($1, $2, $3)", [oyuncu.kullaniciAdi, oyuncu.rol, takim.id] ) oyuncuSayisi++ } for (const ad of takim.sampiyonlar) { const sampiyonId = sampiyonIdleri[ad] if (sampiyonId === undefined) { throw new Error(`${takim.id} numaralı takımda bilinmeyen şampiyon: "${ad}"`) } await istemci.query( "INSERT INTO takim_sampiyonlari (takim_id, sampiyon_id) VALUES ($1, $2)", [takim.id, sampiyonId] ) baglantiSayisi++ } } await istemci.query("COMMIT") console.log(`Taşındı: ${veri.sampiyonlar.length} şampiyon, ${veri.takimlar.length} takım, ${oyuncuSayisi} oyuncu, ${baglantiSayisi} bağlantı`)} catch (hata) { await istemci.query("ROLLBACK") console.error("Taşıma geri alındı:", hata.message) if (hata.detail) console.error("Ayrıntı:", hata.detail) if (hata.hint) console.error("İpucu:", hata.hint)} finally { istemci.release() await pool.end()}Satır satır, yeni olanlar:
const istemci = await pool.connect(): havuzdan bir bağlantı ayırır. Bundan sonraki her komutistemci.queryile, yani bu bağlantıdan gidiyor.pool.queryileBEGINgöndermek işe yaramaz:BEGINbir bağlantıya, sonrakiINSERTbaşka bir bağlantıya düşebilir.count(*)::int: Ders 37'deki tuzak.countbigintdöndürür,pgonu metin olarak verir ('9');::intgerçek bir sayı yapar.sampiyonIdleri: anahtarı şampiyonun adı, değeri veritabanının verdiğiidolan bir obje.RETURNING id(Ders 35) numarayı ekleme komutuyla birlikte geri getirir.OVERRIDING SYSTEM VALUE: Ders 33'te kimlik sütununa elle numara vermeye kalkınca Postgres itiraz etmiş,HINT'te bu sözcükleri önermişti; Ders 35'te yedekten geri yüklerken de gördün. Kayıt taşırken kullanılır: takımlar eski numaralarıyla geliyor, 7 numaralı boşluk da korunuyor.catch: hangi adımda hata çıkarsa çıksınROLLBACKçalışır ve hatanın açıklaması, varsa Postgres'in ayrıntısı (detail) ve ipucu (hint) yazılır.throw new Error(...)ile kendi fırlattığın hatalar da buraya düşer.finally: başarı da hata da olsa bağlantı havuza geri bırakılır ve havuz kapanır.
Çalıştır:
PS C:\kod\takim-api> npm run tasi > takim-api@1.0.0 tasi> node --env-file=.env tasi.js Taşıma geri alındı: 8 numaralı takımda bilinmeyen şampiyon: "lux "İlk kusur bulundu: 8 numaralı takımın listesinde "lux " yazıyor, küçük harfle ve sonunda bir boşlukla. Şampiyon listesinde böyle bir ad yok. Betik durdu, ama önce 14 şampiyonu, yedi takımı ve 14 oyuncuyu eklemişti. Onlara ne oldu? SQL Editor'de bak:
SELECT (SELECT count(*) FROM sampiyonlar) AS sampiyon, (SELECT count(*) FROM takimlar) AS takim, (SELECT count(*) FROM oyuncular) AS oyuncu;| sampiyon | takim | oyuncu |
|---|---|---|
| 0 | 0 | 0 |
Hepsi sıfır: ROLLBACK yarım kalan işi tamamen geri aldı. Parantez içindeki her SELECT tek bir sayı döndürüyor; dış SELECT üçünü yan yana koyuyor. Transaction olmasaydı tablolarda yedi takımlık yarım bir veri kalırdı. Betiği düzeltip yeniden çalıştırdığında sigorta "tablo boş değil" deyip dururdu ve yarım veriyi elle temizlemen gerekirdi.
Yazım farkını betik düzeltsin: adları karşılaştırmadan önce baştaki ve sondaki boşlukları at, harfleri küçült. Şampiyonları kaydederken de ararken de aynı dönüşümü kullan. Şampiyon adları İngilizce olduğu için Ders 32'deki sampiyonHatasi gibi düz toLowerCase kullan. toLocaleLowerCase("tr") burada yanlış olurdu: "Irelia"'nın I'sını ı yapar ve "irelia" ile eşleşmez (Ders 9'daki tuzak).
const veri = JSON.parse(await readFile("veri.json", "utf8")) function anahtar(ad) { return ad.trim().toLowerCase()} const istemci = await pool.connect() sampiyonIdleri[anahtar(ad)] = sonuc.rows[0].id for (const ad of takim.sampiyonlar) { const sampiyonId = sampiyonIdleri[anahtar(ad)]Yeniden çalıştır. Tablolar boş olduğu için baştan başlar:
PS C:\kod\takim-api> npm run tasi > takim-api@1.0.0 tasi> node --env-file=.env tasi.js Taşıma geri alındı: duplicate key value violates unique constraint "takim_sampiyonlari_pkey"Ayrıntı: Key (takim_id, sampiyon_id)=(9, 19) already exists.duplicate key value violates unique constraint "takim_sampiyonlari_pkey"Ne zaman çıkar? Ara tabloya aynı takım–şampiyon çiftini ikinci kez eklemeye kalkınca. Burada sebep veride: 9 numaralı takımın listesi ["Jinx", "Ahri", "Jinx"]. Ayrıntı satırı hangi çiftin tekrar ettiğini söylüyor: 9 numaralı takım, 19 numaralı şampiyon. Kısıtın adı takim_sampiyonlari_pkey: tablonun birincil anahtarı, yani Ders 36'daki bileşik anahtar. Kural tam da bunun için vardı.
Nasıl düzelir? Veriyi eklemeden önce tekrarları ayıkla; aşağıda nasıl yapıldığını göreceksin. Aynı hatayı API'de alırsan istemci aynı şeyi iki kez göndermiştir.
19 mu? Jinx listede 5. sırada, numarası 5 olmalıydı. Şampiyonlar bu kez 1'den değil 15'ten başlayarak numaralandı: ilk denemede eklenip geri alınan 14 şampiyon, numaralarını harcamıştı. ROLLBACK satırları geri alır, numara sayacını almaz. Bunun ne demek olduğunu birazdan, kimlik sayacı bölümünde göreceksin.
Tekrarları ayıklamanın yolunu Ders 32'deki takimHatasi'ndan biliyorsun: boş bir dizi aç, her adı eklemeden önce dizide olup olmadığına includes ile bak. Burada ayrıca her adı önce anahtar ile düzelt; yoksa "Jinx" ile "jinx " farklı sayılır. tasi.js'teki şampiyon döngüsünü şöyle değiştir:
const adlar = [] for (const ad of takim.sampiyonlar) { const temiz = anahtar(ad) if (!adlar.includes(temiz)) { adlar.push(temiz) } } for (const ad of adlar) { const sampiyonId = sampiyonIdleri[ad]adlar dizisine giren her ad zaten düzeltilmiş olduğu için aramada artık anahtar(ad) yazmaya gerek yok. Bu kez numaralar da 1'den başlasın: önce npm run kur ile tabloları baştan kur, sonra taşı:
PS C:\kod\takim-api> npm run kur > takim-api@1.0.0 kur> node --env-file=.env kur.js Şema kurulduPS C:\kod\takim-api> npm run tasi > takim-api@1.0.0 tasi> node --env-file=.env tasi.js Taşındı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıTaşıma tamam. Bir kez daha çalıştırıp sigortayı dene:
PS C:\kod\takim-api> npm run tasi > takim-api@1.0.0 tasi> node --env-file=.env tasi.js Taşıma geri alındı: takimlar tablosu boş değil, taşıma daha önce yapılmışBetik bu kez hiçbir şeye dokunmadan durdu. Baştan taşımak istersen önce npm run kur, sonra npm run tasi.
Taşımanın sonucunu SQL Editor'de denetle. Sayılar betiğin söyledikleriyle aynı olmalı:
SELECT (SELECT count(*) FROM sampiyonlar) AS sampiyon, (SELECT count(*) FROM takimlar) AS takim, (SELECT count(*) FROM oyuncular) AS oyuncu, (SELECT count(*) FROM takim_sampiyonlari) AS baglanti;| sampiyon | takim | oyuncu | baglanti |
|---|---|---|---|
| 14 | 9 | 15 | 18 |
İki kusurlu takımın şampiyonlarına da bak:
SELECT t.ad AS takim, s.ad AS sampiyonFROM takim_sampiyonlari tsJOIN takimlar t ON t.id = ts.takim_idJOIN sampiyonlar s ON s.id = ts.sampiyon_idWHERE t.id IN (8, 9)ORDER BY t.id, s.ad;| takim | sampiyon |
|---|---|
| Kristal Kalkan | Garen |
| Kristal Kalkan | Lux |
| Son Vuruş | Ahri |
| Son Vuruş | Jinx |
"lux " doğru şampiyona, Lux'a bağlandı; Jinx bir kez sayıldı. Veri, dosyadakinden daha temiz bir hâlde taşındı.
Kimlik sayacı: taşımanın gizli tuzağı
Taşıma bitti, her şey yerinde görünüyor. SQL Editor'den yeni bir takım eklemeyi dene:
INSERT INTO takimlar (ad) VALUES ('Deneme Takımı');ERROR: duplicate key value violates unique constraint "takimlar_pkey"DETAIL: Key (id)=(1) already exists.Postgres yeni takıma 1 numarayı vermeye kalktı. Neden 1? Kimlik sütunu numaraları kafasından uydurmaz; arkasında sıradaki numarayı tutan bir sayaç vardır. Sayaç yalnız numarayı kendisi verdiğinde ilerler. Taşıma sırasında takımlar OVERRIDING SYSTEM VALUE ile kendi numaralarını getirdi; sayaç bu sırada hiç kullanılmadı ve hâlâ en baştan, 1'den saymaya hazır.
setval sayacı en büyük numaraya getirince sıradaki takım 11'i alır.Sayacı ileri sarmak için iki fonksiyon kullanacaksın: pg_get_serial_sequence('takimlar', 'id') bir sütunun arkasındaki sayacın adını bulur, setval(sayac, deger) sayacı o değere getirir. Değer olarak tablodaki en büyük id'yi ver:
SELECT setval(pg_get_serial_sequence('takimlar', 'id'), max(id))FROM takimlar;| setval |
|---|
| 10 |
Sayaç artık 10'da; sıradaki takım 11 numarayı alacak. oyuncular ve sampiyonlar tablolarını düzeltmeye gerek yok: onlara numarayı taşıma sırasında da sayaç verdi.
Elle yaptığın bu düzeltmeyi betiğe de ekle. Betiği bir gün canlı veritabanında, temiz tablolarla çalıştıracaksın; orada da aynı tuzak bekliyor. tasi.js'te COMMIT'in hemen üstüne:
await istemci.query( "SELECT setval(pg_get_serial_sequence('takimlar', 'id'), max(id)) FROM takimlar" ) await istemci.query("COMMIT")duplicate key value violates unique constraint "takimlar_pkey"Ne zaman çıkar? Eski numaralarla taşınan bir tabloya, sayaç ileri sarılmadan yeni satır eklenince. İlk denemede Key (id)=(1) already exists alırsın; aynı isteği tekrarladıkça 2, 3 diye ilerler, çünkü her başarısız deneme sayaçtan bir numara harcar. Bu tabloda 7 numara boş olduğu için yedinci deneme başarılı olur ve sorun kendiliğinden geçmiş gibi görünür; sekizinci denemede yine patlar. Birazdan API'ye bağlayacağın hata çevirisi 23505'i 409 Conflict'e çevirdiği için ekranda "zaten var" yazar ve sen var olmayan bir çakışmayı ararsın.
Nasıl düzelir? DETAIL satırına bak: çakışan sütun id ise sorun veride değil sayaçta. setval(pg_get_serial_sequence('tablo', 'id'), max(id)) ile sayacı en büyük numaraya getir ve eski numaralarla taşıyan her betiğin sonuna bu satırı koy.
Uç noktaları SQL'e çevirmek
Veri yerinde. Şimdi sunucu.js'teki uç noktaları dosya yerine veritabanıyla çalışacak hâle getireceksin. Her uç noktadaki dizi işleminin bir SQL karşılığı var:
| İş | veri.json ile | PostgreSQL ile |
|---|---|---|
| hepsini listele | veri.takimlar | SELECT ... FROM takimlar |
| tek takımı bul | veri.takimlar.find(...) | SELECT ... WHERE id = $1 |
| ekle | push, sonId + 1, writeFile | INSERT ... RETURNING id |
| adını değiştir | find, atama, writeFile | UPDATE ... SET ad = $1 WHERE id = $2 |
| sil | some, filter, writeFile | DELETE ... WHERE id = $1 |
sunucu.js'in en üstünde artık node:fs/promises'e, node:path'e, veriDosyasi'na ve veriyiOku, veriyiYaz fonksiyonlarına gerek yok. Onları sil, havuzu al:
import express from "express"import { randomUUID } from "node:crypto"import { pool } from "./db.js"import { adHatasi, takimHatasi, sampiyonHatasi, sayfaBilgisi } from "./dogrula.js" const app = express()const port = process.env.PORT ?? 3000const saltOkunur = process.env.SALT_OKUNUR === "evet"İstek kimliği, kayıt ve salt okunur ara katmanlarına, express.json()'a, 404 ara katmanına ve hata yakalayıcıya dokunmuyorsun; onlar veriyi hiç görmüyor. app.listen'daki Veri dosyası: ... satırını sil. Eski POST /api/takimlar'ı da şimdilik sil; birden çok tabloya yazdığı için ona ayrı bir bölüm ayırdık. Geri kalan route'ları aşağıda tek tek değiştireceksin. Uygulamayı npm run dev ile başlat ve ikinci bir terminal aç:
PS C:\kod\takim-api> npm run dev > takim-api@1.0.0 dev> node --watch --env-file=.env sunucu.js Takım API'si çalışıyor: http://localhost:3000Tek takım: üç sorgu, bir JSON
Tek takımın JSON'u üç tablodan geliyor: takımın kendisi, oyuncuları ve şampiyonları. Üç küçük sorgu yazıp sonuçları birleştiren bir fonksiyon kur; birden çok uç noktada kullanacaksın. app.use(express.json()) satırının altına:
async function takimGetir(id) { const takimSonucu = await pool.query( "SELECT id, ad, olusturulma FROM takimlar WHERE id = $1", [id] ) if (takimSonucu.rows.length === 0) { return null } const oyuncuSonucu = await pool.query( `SELECT kullanici_adi AS "kullaniciAdi", rol FROM oyuncular WHERE takim_id = $1 ORDER BY id`, [id] ) const sampiyonSonucu = await pool.query( `SELECT s.ad FROM takim_sampiyonlari ts JOIN sampiyonlar s ON s.id = ts.sampiyon_id WHERE ts.takim_id = $1 ORDER BY s.ad`, [id] ) return { ...takimSonucu.rows[0], oyuncular: oyuncuSonucu.rows, sampiyonlar: sampiyonSonucu.rows.map((satir) => satir.ad) }}- Takım yoksa
nulldöndürüyor; 404'e uç nokta karar verecek. kullanici_adi AS "kullaniciAdi": tablodaki snake_case adı JSON'un camelCase adına çeviren bir takma ad. Tırnaklar süs değil: Postgres tırnaksız adları küçük harfe çevirir ve tırnağı unutursan alan sessizcekullaniciadiolur (Ders 37). Çift tırnaklı SQL'i bu yüzden ters tırnaklı metne yazdık.- Şampiyon sorgusu ara tablodan şampiyon adlarına gidiyor.
rows,{ ad: 'Ahri' }gibi objelerden bir dizi;maponu eski API'deki gibi düz bir ad dizisine çeviriyor:["Ahri", "Garen", "Lux"]. { ...takimSonucu.rows[0], ... }: takımın satırını yayıp (Ders 12) iki listeyi yanına ekliyor.
Şimdi tek takım uç noktası. Eskisiyle yan yana:
app.get("/api/takimlar/:id", async (req, res) => { const veri = await veriyiOku() const takim = veri.takimlar.find((t) => t.id === Number(req.params.id)) if (!takim) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.json(takim)})app.get("/api/takimlar/:id", async (req, res) => { const takim = await takimGetir(req.params.id) if (!takim) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.json(takim)})Yeni sürüm, bütün dosyayı okuyup aramak yerine veritabanına yalnız istediği takımı soruyor. req.params.id bir metin ("1"); onu sayıya çevirmedik, çünkü parametre olarak gönderilen metni Postgres integer sütunla karşılaştırırken kendisi çeviriyor. Dene:
PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/1{"id":1,"ad":"Gece Kurtları","olusturulma":"2026-09-01T18:30:00.000Z","oyuncular":[{"kullaniciAdi":"deniz","rol":"orta"},{"kullaniciAdi":"ece","rol":"destek"}],"sampiyonlar":["Ahri","Garen","Lux"]}PS C:\kod\takim-api> Invoke-RestMethod http://localhost:3000/api/takimlar/1 id : 1ad : Gece Kurtlarıolusturulma : 2026-09-01T18:30:00.000Zoyuncular : {@{kullaniciAdi=deniz; rol=orta}, @{kullaniciAdi=ece; rol=destek}}sampiyonlar : {Ahri, Garen, Lux} Alan adları ve biçim eski API'yle aynı. olusturulma bile aynı görünüyor: Postgres'in timestamptz değeri JavaScript'e Date olarak geliyor, res.json onu eski dosyadaki ISO biçimine çeviriyor (Ders 37'deki tip tablosu). Tek fark şampiyonların sırası.
Liste: sayılarla özet
Ders 32'deki liste her takımı oyuncuları ve şampiyonlarıyla birlikte döndürüyordu. Veritabanında aynısını yapmak her takım için takimGetir'deki iki sorguyu daha çalıştırmak demek: 50 takımlık bir sayfada 1 + 100 sorgu. Bu yaygın tuzağın bir adı var.
Listede takımın yalnız özetini vereceğiz: kaç oyuncusu, kaç şampiyonu var. Ayrıntı isteyen tek takım adresine gider; API'lerde yaygın bir düzen. Bu, sözleşmede bilerek yaptığımız ilk değişiklik ve README'ye yazacağız. Aramayı ve sayfalamayı da şimdilik bir kenara bırak; onları birazdan SQL ile geri getireceksin.
Önce sorguyu SQL Editor'de kur. Takımı oyuncularına ve şampiyonlarına bağlayıp say; denemek için üç takım yeter:
SELECT t.id, t.ad, count(o.id) AS oyuncu, count(ts.sampiyon_id) AS sampiyonFROM takimlar tJOIN oyuncular o ON o.takim_id = t.idJOIN takim_sampiyonlari ts ON ts.takim_id = t.idWHERE t.id IN (1, 9, 10)GROUP BY t.idORDER BY t.id;| id | ad | oyuncu | sampiyon |
|---|---|---|---|
| 1 | Gece Kurtları | 6 | 6 |
| 9 | Son Vuruş | 2 | 2 |
İki yanlış birden var. Gece Kurtları'nın 2 oyuncusu ve 3 şampiyonu var ama ikisi de 6 sayıldı. Gölge Takımı (10) ise hiç görünmüyor. İkincisini Ders 36'dan tanıyorsun: INNER JOIN eşi olmayan takımı atar, LEFT JOIN gerekir. Birincisi yeni: iki ayrı bire-çok ilişkiyi aynı sorguda birleştirince satırlar katlanır. Postgres her oyuncuyu her şampiyonla eşler: 2 oyuncu × 3 şampiyon = 6 satır, count de satırları sayar. count(DISTINCT sütun) ise yalnız farklı değerleri sayar:
SELECT t.id, t.ad, count(DISTINCT o.id) AS oyuncu, count(DISTINCT ts.sampiyon_id) AS sampiyonFROM takimlar tLEFT JOIN oyuncular o ON o.takim_id = t.idLEFT JOIN takim_sampiyonlari ts ON ts.takim_id = t.idWHERE t.id IN (1, 9, 10)GROUP BY t.idORDER BY t.id;| id | ad | oyuncu | sampiyon |
|---|---|---|---|
| 1 | Gece Kurtları | 2 | 3 |
| 9 | Son Vuruş | 1 | 2 |
| 10 | Gölge Takımı | 0 | 0 |
GROUP BY t.id ile t.ad'ı da seçebildik; Ders 36'da gördüğün gibi birincil anahtara göre gruplayınca o tablonun öbür sütunları serbest. Sorgu doğru; şimdi uç noktaya koy. Sayıların metin olarak gitmemesi için Ders 37'deki ::int'i, JSON'daki adlar için tırnaklı takma adları unutma:
app.get("/api/takimlar", async (req, res) => { const { rows } = await pool.query( `SELECT t.id, t.ad, t.olusturulma, count(DISTINCT o.id)::int AS "oyuncuSayisi", count(DISTINCT ts.sampiyon_id)::int AS "sampiyonSayisi" FROM takimlar t LEFT JOIN oyuncular o ON o.takim_id = t.id LEFT JOIN takim_sampiyonlari ts ON ts.takim_id = t.id GROUP BY t.id ORDER BY t.id` ) res.json(rows)})PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar[{"id":1,"ad":"Gece Kurtları","olusturulma":"2026-09-01T18:30:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":3},{"id":2,"ad":"Mavi Ejderler","olusturulma":"2026-09-02T09:15:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":3},{"id":3,"ad":"Baron Avcıları","olusturulma":"2026-09-03T20:00:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":4,"ad":"Kuzey Rüzgarı","olusturulma":"2026-09-05T11:45:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":5,"ad":"Ateş Kuşları","olusturulma":"2026-09-09T19:40:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":6,"ad":"Kurt Sürüsü","olusturulma":"2026-09-12T21:10:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":8,"ad":"Kristal Kalkan","olusturulma":"2026-09-20T16:20:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":9,"ad":"Son Vuruş","olusturulma":"2026-09-24T13:05:00.000Z","oyuncuSayisi":1,"sampiyonSayisi":2},{"id":10,"ad":"Gölge Takımı","olusturulma":"2026-10-01T08:00:00.000Z","oyuncuSayisi":0,"sampiyonSayisi":0}]ORDER BY t.id, Ders 32'deki dosya sırasını koruyor: en eski takım başta.
PATCH ve DELETE
Değiştirme ve silme uç noktaları tek sorguluk. Adın kuralını yine dogrula.js'teki adHatasi denetliyor:
app.patch("/api/takimlar/:id", async (req, res) => { const hata = adHatasi(req.body?.ad) if (hata) { return res.status(400).json({ hata }) } const { rowCount } = await pool.query( "UPDATE takimlar SET ad = $1 WHERE id = $2", [req.body.ad.trim(), req.params.id] ) if (rowCount === 0) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.json(await takimGetir(req.params.id))}) app.delete("/api/takimlar/:id", async (req, res) => { const { rowCount } = await pool.query("DELETE FROM takimlar WHERE id = $1", [req.params.id]) if (rowCount === 0) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.status(204).end()})- Takımın var olup olmadığını ayrıca sormadık.
rowCountkaç satırın değiştiğini ya da silindiğini söyler; 0 ise öyle bir takım yoktu (Ders 37). PATCHeskisi gibi takımın tamamını döndürüyor; sözleşme bunu söylüyordu.UPDATE'ten sonra takımıtakimGetirile yeniden okuyoruz.- Takımı silince
ON DELETE CASCADEonun oyuncularını ve şampiyon bağlantılarını da siler. BirDELETEüç tablodan satır götürür; Ders 36'daki "CASCADE sessizdir" uyarısını hatırla.
Gölge Takımı'nın adını değiştir. JSON gövdeyi Ders 37'deki gibi bir dosyadan gönder: proje klasöründe istekler adında bir klasör aç, içine ad.json dosyasını koy ve içine { "ad": "Gölge Ordusu" } yaz:
PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\ad.json" http://localhost:3000/api/takimlar/10{"id":10,"ad":"Gölge Ordusu","olusturulma":"2026-10-01T08:00:00.000Z","oyuncular":[],"sampiyonlar":[]}PS C:\kod\takim-api> curl.exe -i -X PATCH --json "@istekler\ad.json" http://localhost:3000/api/takimlar/7HTTP/1.1 404 Not FoundX-Powered-By: ExpressX-Istek-Kimligi: 768bb280Content-Type: application/json; charset=utf-8Content-Length: 29ETag: W/"1d-dROtwCE5hGBhioJB963Igac7VbI"Date: Mon, 12 Oct 2026 00:22:54 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"hata":"Takım bulunamadı"}7 numara taşımadan önce silinmişti; 404 doğru. Bu adı ders boyunca böyle bırak; sonraki çıktılarda Gölge Ordusu olarak göreceksin.
Şampiyon uç noktaları: ad tek bir yerde
Ders 32'de şampiyon havuzu dosyada bir ad dizisiydi ve her takım şampiyonlarını adıyla tutuyordu. Bir şampiyonun adını değiştirmek bu yüzden bütün takımları dolaşan bir döngü gerektiriyordu; o dersteki bilgi kutusu "veritabanları bu sorunu bilgiyi tek bir yerde tutup ona numarasıyla bağlanarak çözer" demişti. Şimdi o an. Havuzu birkaç uç nokta kullanacağı için onu getiren küçük bir fonksiyonla başla, takimGetir'in üstüne:
async function havuzuGetir() { const { rows } = await pool.query("SELECT ad FROM sampiyonlar ORDER BY ad") return rows.map((satir) => satir.ad)}dogrula.js'teki sampiyonHatasi(ad, havuz) ve takimHatasi(govde, havuz) havuzu bir ad dizisi olarak bekliyordu; havuzuGetir tam onu, bu kez tablodan veriyor. dogrula.js'e dokunman gerekmiyor. Dört şampiyon route'unu şöyle değiştir:
app.get("/api/sampiyonlar", async (req, res) => { res.json(await havuzuGetir())}) app.post("/api/sampiyonlar", async (req, res) => { const hata = sampiyonHatasi(req.body?.ad, await havuzuGetir()) if (hata) { return res.status(400).json({ hata }) } const { rows } = await pool.query( "INSERT INTO sampiyonlar (ad) VALUES ($1) RETURNING ad", [req.body.ad.trim()] ) res.status(201).json(rows[0])}) app.patch("/api/sampiyonlar/:ad", async (req, res) => { const havuz = await havuzuGetir() const eskiAd = req.params.ad if (!havuz.includes(eskiAd)) { return res.status(404).json({ hata: "Şampiyon bulunamadı" }) } const hata = sampiyonHatasi(req.body?.ad, havuz) if (hata) { return res.status(400).json({ hata }) } const yeniAd = req.body.ad.trim() const { rows } = await pool.query( "UPDATE sampiyonlar SET ad = $1 WHERE ad = $2 RETURNING id", [yeniAd, eskiAd] ) const sayim = await pool.query( "SELECT count(*)::int AS sayi FROM takim_sampiyonlari WHERE sampiyon_id = $1", [rows[0].id] ) res.json({ eskiAd, yeniAd, takimSayisi: sayim.rows[0].sayi })}) app.delete("/api/sampiyonlar/:ad", async (req, res) => { const { rowCount } = await pool.query("DELETE FROM sampiyonlar WHERE ad = $1", [req.params.ad]) if (rowCount === 0) { return res.status(404).json({ hata: "Şampiyon bulunamadı" }) } res.status(204).end()})- Ad değiştirmek artık tek bir
UPDATE. Takımlar şampiyonu adıyla değil numarasıyla tuttuğu için hiçbirine dokunmak gerekmedi: Ders 36'daki normalizasyonun ödülü.takimSayisiyanıtın eski biçimi korunsun diye ayrıca sayılıyor. - Silme route'u "bu şampiyonu kullanan takım var mı?" diye sormuyor.
ON DELETE RESTRICTo işi veritabanında yapıyor; ne olduğunu bir sonraki bölümde göreceksin.
istekler klasörüne luxanna.json ({ "ad": "Luxanna" }) ve lux.json ({ "ad": "Lux" }) dosyalarını koy:
PS C:\kod\takim-api> curl.exe http://localhost:3000/api/sampiyonlar["Ahri","Annie","Brand","Garen","Jinx","Lee Sin","Lux","Morgana","Sona","Teemo","Thresh","Vi","Yasuo","Zed"]PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\luxanna.json" http://localhost:3000/api/sampiyonlar/Lux{"eskiAd":"Lux","yeniAd":"Luxanna","takimSayisi":3}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/8{"id":8,"ad":"Kristal Kalkan","olusturulma":"2026-09-20T16:20:00.000Z","oyuncular":[{"kullaniciAdi":"burak","rol":"destek"},{"kullaniciAdi":"defne","rol":"alt"}],"sampiyonlar":["Garen","Luxanna"]}PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\lux.json" http://localhost:3000/api/sampiyonlar/Luxanna{"eskiAd":"Luxanna","yeniAd":"Lux","takimSayisi":3}PS C:\kod\takim-api> curl.exe -X DELETE http://localhost:3000/api/sampiyonlar/Zedd{"hata":"Şampiyon bulunamadı"}Tek satır değişti, üç takım yeni adı gördü; sonra adı geri aldık.
Dosyayı okuyan son yer /saglik. Ders 32'deki sürümü takım sayısını dosyadan alıyordu; artık tablodan saysın:
app.get("/saglik", async (req, res) => { const { rows } = await pool.query("SELECT count(*)::int AS sayi FROM takimlar") res.json({ durum: "iyi", calismaSuresi: Math.floor(process.uptime()), surum: process.env.RENDER_GIT_COMMIT?.slice(0, 7) ?? "yerel", takimSayisi: rows[0].sayi })})PS C:\kod\takim-api> curl.exe http://localhost:3000/saglik{"durum":"iyi","calismaSuresi":5,"surum":"yerel","takimSayisi":9}Veritabanına ulaşılamazsa sorgu hata verir ve uç nokta 500 döner. Sağlık ucu artık yalnız "program açık mı?" değil, "veritabanına ulaşabiliyor mu?" sorusunu da cevaplıyor.
Veritabanı hatalarını HTTP'ye çevirmek
Bir kullanıcı takım numarası yerine yanlışlıkla bir sözcük yazarsa ne olur? Ders 32'de Number("abc") NaN veriyordu ve cevap 404'tü. Şimdi:
PS C:\kod\takim-api> curl.exe -i http://localhost:3000/api/takimlar/abcHTTP/1.1 500 Internal Server ErrorX-Powered-By: ExpressX-Istek-Kimligi: 57edd888Content-Type: application/json; charset=utf-8Content-Length: 59ETag: W/"3b-Qmo+KacH4vdJeBIg6uYzQt6Pg6U"Date: Mon, 12 Oct 2026 00:23:01 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"hata":"Sunucuda bir hata oldu","istekKimligi":"57edd888"}500, "sunucuda bir hata oldu" demek; ama hatalı olan sunucu değil, istek. Doğru cevap 400. Ders 32'deki hata yakalayıcı hatayı istek kimliğiyle birlikte terminale yazmıştı:
[57edd888] error: invalid input syntax for type integer: "abc" at C:\kod\takim-api\node_modules\pg-pool\index.js:45:11 at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async takimGetir (file:///C:/kod/takim-api/sunucu.js:40:25) at async file:///C:/kod/takim-api/sunucu.js:80:19 { length: 146, severity: 'ERROR', code: '22P02', ... routine: 'pg_strtoint32_safe'}[57edd888] GET /api/takimlar/abc 500 3 msPostgres "abc"'yi integer'a çeviremedi. Yığın izini Ders 13'teki gibi oku: hata takimGetir'in ilk sorgusunda çıktı, onu tek takım uç noktası çağırdı. Asıl değerli bilgi ise code: '22P02' satırında: SQLSTATE kodu. Ders 37'de tam bu iş için hatalar.js'te pgHatasiniCevir fonksiyonunu yazmıştın. C:\kod\pg-api\hatalar.js'i bu projeye kopyala; denemek için eklediğin console.log satırlarını sildiysen içinde yalnız fonksiyon kalmıştır. Ders 37'nin üçüncü görevini yaptıysan 11. satırda 22003 de yazar; birazdan ona da değineceğiz.
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}Şimdi sunucu.js'e bağla. Üste import { pgHatasiniCevir } from "./hatalar.js" satırını ekle ve hata yakalayıcıya, bozuk JSON denetiminin hemen altına dört satır koy:
app.use((hata, req, res, next) => { if (hata.type === "entity.parse.failed") { return res.status(400).json({ hata: "Gövde geçerli bir JSON değil" }) } const cevap = pgHatasiniCevir(hata, req.method) if (cevap) { return res.status(cevap.durum).json({ hata: cevap.mesaj }) } console.error(`[${req.kimlik}]`, hata) res.status(500).json({ hata: "Sunucuda bir hata oldu", istekKimligi: req.kimlik })})Tanınan hatalar istemciye kısa, Türkçe bir cümleyle dönüyor; tanınmayanlar eskisi gibi terminale yazılıp 500 alıyor. Ders 37'deki kural geçerli: Postgres'in ham mesajını ve kısıt adlarını istemciye gönderme. Kaydet ve dene:
PS C:\kod\takim-api> curl.exe -i http://localhost:3000/api/takimlar/abcHTTP/1.1 400 Bad RequestX-Powered-By: ExpressX-Istek-Kimligi: db3123afContent-Type: application/json; charset=utf-8Content-Length: 50ETag: W/"32-cgFCQcNval9JK8ynOKMg4Ry9QCE"Date: Mon, 12 Oct 2026 00:23:28 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"hata":"Gönderilen değerlerden biri geçersiz"}Kullanılan bir şampiyonu silmeyi dene. Annie iki takımda var; ON DELETE RESTRICT silmeyi reddetmeli ve Ders 37'deki tabloya göre 409 gelmeli:
409 değil 500 veriyor: violates RESTRICT setting of foreign key constraintNe zaman çıkar? ON DELETE RESTRICT ile korunan bir satırı API'den silmeye kalkınca, hata çevirin yalnız 23503'ü tanıyorsa.
PS C:\kod\takim-api> curl.exe -X DELETE http://localhost:3000/api/sampiyonlar/Annie{"hata":"Sunucuda bir hata oldu","istekKimligi":"3e939229"}[3e939229] error: update or delete on table "sampiyonlar" violates RESTRICT setting of foreign key constraint "takim_sampiyonlari_sampiyon_id_fkey" on table "takim_sampiyonlari" at C:\kod\takim-api\node_modules\pg-pool\index.js:45:11 at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async file:///C:/kod/takim-api/sunucu.js:150:26 { length: 354, severity: 'ERROR', code: '23001', detail: 'Key (id)=(2) is referenced from table "takim_sampiyonlari".', ... constraint: 'takim_sampiyonlari_sampiyon_id_fkey', ...}[3e939229] DELETE /api/sampiyonlar/Annie 500 43 msKural işledi, Annie silinmedi; ama kod 23503 değil 23001. Mesajı Ders 36'daki hata kartından tanıyorsun: PostgreSQL 18'de RESTRICT kendi mesajını verir, kodu da ayrıdır (restrict_violation). Ders 37'deki silme denemesinde kısıtta ON DELETE yazılmamıştı (varsayılan davranış), o yüzden 23503 gelmişti. Çevirin bu kodu tanımadığı için hata 500'e düştü. Neon projen PostgreSQL 17 ya da daha eskiyse RESTRICT de 23503 verir ve bu istek doğrudan 409 alır; aşağıdaki ekleme yine de zarar vermez.
Nasıl düzelir? Kodu, kısıtın sınıfına bakarak çeviriye ekle: 23 ile başlayan her kod bir kural ihlalidir. 23001'i 23503 ile aynı dala koy; silmede 409.
Bir sınır daha dene: integer'ın en büyük değeri 2.147.483.647; on bir haneli bir numara sığmaz.
PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/99999999999{"hata":"Sunucuda bir hata oldu","istekKimligi":"274bac59"}Sunucunun terminalinde error: value "99999999999" is out of range for type integer ve code: '22003' yazıyor: sayı tipin sınırını aştı. Bu da istemcinin hatası. Bu kodu Ders 37'nin üçüncü görevinde tanımıştın; o görevi yaptıysan çevirinde zaten var ve bu istek 500 değil 400 alır. Eksik olan kodları ekle ve Ders 37'deki gibi, sunucuya bağlamadan önce sahte hata objeleriyle dene. Dosyanın sonuna dört console.log satırı ekleyip node hatalar.js ile çalıştır:
export function pgHatasiniCevir(hata, metot) { if (hata.code === "23505") { return { durum: 409, mesaj: "Bu kayıt zaten var" } } if (hata.code === "23503" || hata.code === "23001") { 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 === "22003" || hata.code === "23502" || hata.code === "23514") { return { durum: 400, mesaj: "Gönderilen değerlerden biri geçersiz" } } return null} console.log(pgHatasiniCevir({ code: "23001" }, "DELETE"))console.log(pgHatasiniCevir({ code: "22003" }, "GET"))console.log(pgHatasiniCevir({ code: "23503" }, "POST"))console.log(pgHatasiniCevir({ code: "42804" }, "GET")){ durum: 409, mesaj: 'Bu kayda bağlı başka kayıtlar var' }{ durum: 400, mesaj: 'Gönderilen değerlerden biri geçersiz' }{ durum: 400, mesaj: 'Bağlandığı kayıt bulunamadı' }nullFonksiyon yalnız code alanına bakıyor; gerisi önemli değil. 42804 (tip uyuşmazlığı) kodun kendi hatasıdır, istemcinin değil; null döner ve 500 kalır. Bu kodu az sonra arama bölümünde göreceksin. Denedikten sonra dört console.log satırını sil; --watch sunucuyu kendiliğinden yeniden başlatır.
istekler klasörüne kisa-ad.json ({ "ad": "G" }) ve kapanmamış bir JSON içeren bozuk.json ({ "ad": "Gölge Ordusu" ) dosyalarını koy ve sınırları dene:
PS C:\kod\takim-api> curl.exe -i -X DELETE http://localhost:3000/api/sampiyonlar/AnnieHTTP/1.1 409 ConflictX-Powered-By: ExpressX-Istek-Kimligi: 5ad5e64cContent-Type: application/json; charset=utf-8Content-Length: 48ETag: W/"30-D2+icqr6SizgHA4qcKTVwsP1gQU"Date: Mon, 12 Oct 2026 00:23:38 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"hata":"Bu kayda bağlı başka kayıtlar var"}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/99999999999{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/1.5{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/-1{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\kisa-ad.json" http://localhost:3000/api/takimlar/10{"hata":"ad 2 ile 40 karakter arasında olmalı"}PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\bozuk.json" http://localhost:3000/api/takimlar/10{"hata":"Gövde geçerli bir JSON değil"}-1 geçerli bir sayı ama böyle bir takım yok: 404. Tek harfli ad ise veritabanına hiç ulaşmadı; adHatasi onu Ders 32'deki açık mesajıyla durdurdu. O hâlde CHECK boşuna mı? Hayır: dogrula.js yalnız API'den gelen istekleri görür. SQL Editor'den, taşıma betiğinden ya da ileride yazacağın başka bir programdan gelen veriyi ise yalnız veritabanı durdurabilir:
UPDATE takimlar SET ad = 'G' WHERE id = 10;ERROR: new row for relation "takimlar" violates check constraint "takimlar_ad_check"DETAIL: Failing row contains (10, G, 2026-10-01 08:00:00+00).İki kat duvar: önde açık mesaj veren dogrula.js, arkada kimsenin atlayamadığı kurallar. API'den bir gün 23514 gelirse çeviri onu da 400'e çevirir. takimlar_ad_check, Postgres'in kurala kendiliğinden verdiği ad: tablo, sütun ve check.
Sayfalama: LIMIT ve OFFSET
Ders 32'de sayfalamayı dosyadaki dizinin slice'ıyla yapmıştın: her istekte bütün takımları okuyup çoğunu atıyordun. 10.000 takımda bu, her istekte 10.000 takımı diskten okumak demekti. Ders 34'te aynı işi veritabanına LIMIT ve OFFSET ile yaptırmayı gördün: veritabanı yalnız istenen sayfayı gönderir. Şimdi listeyi dogrula.js'teki sayfaBilgisi ile birleştir:
app.get("/api/takimlar", async (req, res) => { const { sayfa, boyut, hata } = sayfaBilgisi(req.query) if (hata) { return res.status(400).json({ hata }) } const sayim = await pool.query("SELECT count(*)::int AS toplam FROM takimlar") const { rows } = await pool.query( `SELECT t.id, t.ad, t.olusturulma, count(DISTINCT o.id)::int AS "oyuncuSayisi", count(DISTINCT ts.sampiyon_id)::int AS "sampiyonSayisi" FROM takimlar t LEFT JOIN oyuncular o ON o.takim_id = t.id LEFT JOIN takim_sampiyonlari ts ON ts.takim_id = t.id GROUP BY t.id ORDER BY t.id LIMIT $1 OFFSET $2`, [boyut, (sayfa - 1) * boyut] ) res.set("X-Toplam-Sayi", sayim.rows[0].toplam) res.json(rows)})sayfaBilgisiDers 32'deki gibisayfaveboyut'u doğruluyor: varsayılanlar 1 ve 10,boyuten çok 50. Sınırsız bırakırsan biriboyut=1000000yazıp sayfalamayı boşa çıkarır.(sayfa - 1) * boyutatlanacak takım sayısı: 3. sayfa ve boyut 4 ise ilk 8 takım atlanır.LIMITveOFFSET'e de değerleri parametreyle verdik. Ne kadar güvenli görünürse görünsün, adresten gelen hiçbir şey sorgu metnine yapıştırılmaz.- Toplam için ayrı bir
countsorgusu var; sonuç yineX-Toplam-Sayibaşlığında. Sözleşme korunuyor. ORDER BY t.id: Ders 34'teki kural, sayfalamada sıra benzersiz bir sütuna dayanmalı; yoksa bir takım iki sayfada birden görünebilir.
PS C:\kod\takim-api> curl.exe -i "http://localhost:3000/api/takimlar?boyut=3&sayfa=2"HTTP/1.1 200 OKX-Powered-By: ExpressX-Istek-Kimligi: 5df0273cX-Toplam-Sayi: 9Content-Type: application/json; charset=utf-8Content-Length: 327ETag: W/"147-ei2La4tm+hsSwTLVRZ5XvsOjdbg"Date: Mon, 12 Oct 2026 00:23:46 GMTConnection: keep-aliveKeep-Alive: timeout=5 [{"id":4,"ad":"Kuzey Rüzgarı","olusturulma":"2026-09-05T11:45:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":5,"ad":"Ateş Kuşları","olusturulma":"2026-09-09T19:40:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2},{"id":6,"ad":"Kurt Sürüsü","olusturulma":"2026-09-12T21:10:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2}]PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/takimlar?boyut=3&sayfa=4"[]PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/takimlar?sayfa=0"{"hata":"sayfa 1 ya da daha büyük bir tam sayı olmalı"}İkinci sayfa birinci sayfanın bittiği yerden, 4 numaradan başladı. Son sayfadan sonrası hata değil, boş bir liste; X-Toplam-Sayi istemciye nerede duracağını söylüyor: 9 takım, 3'erli sayfa, 3 sayfa. Adresleri tırnak içine aldık: PowerShell'de tırnaksız & Ders 26'daki hatayı verir.
OFFSET'in iki zayıf yanı
LIMIT/OFFSET kurması en kolay sayfalama ve küçük tablolarda tam yeterli. İki zayıf yanını bil.
Uzak sayfalar yavaşlar. OFFSET 100000, Postgres'e "100.000 satır atla" der; ama atlamak için o satırları yine bulup sıralaması gerekir. Sayfa numarası büyüdükçe her istek biraz daha pahalı olur.
Sayfa okunurken liste kayar. Sen 1. sayfaya bakarken biri bir takımı silerse her şey bir sıra yukarı çıkar. SQL Editor'de bunu bir transaction içinde, hiçbir şeyi kalıcı yapmadan dene. Önce 2. sayfa:
SELECT id, ad FROM takimlar ORDER BY id LIMIT 3 OFFSET 3;| id | ad |
|---|---|
| 4 | Kuzey Rüzgarı |
| 5 | Ateş Kuşları |
| 6 | Kurt Sürüsü |
Şimdi 2 numaralı takım silinmiş gibi yap ve aynı sayfayı yeniden iste. Sondaki ROLLBACK silmeyi geri alır:
BEGIN;DELETE FROM takimlar WHERE id = 2;SELECT id, ad FROM takimlar ORDER BY id LIMIT 3 OFFSET 3;ROLLBACK;| id | ad |
|---|---|
| 5 | Ateş Kuşları |
| 6 | Kurt Sürüsü |
| 8 | Kristal Kalkan |
Kuzey Rüzgarı 1. sayfanın sonuna kaydı. Sen 1. sayfayı ondan önce okuduğun için onu hiç görmeyeceksin. Ekleme olursa tersi olur: yeni satır sıralamada önce geliyorsa bir takım bir sonraki sayfaya kayar ve onu iki kez görürsün.
OFFSET 3 artık 5'ten başlar; 4 numara 1. sayfaya geçtiği için hiç görünmez.Çoğu uygulama bu küçük kaymayla yaşar. Sonsuz kaydırılan akışlar ve dev tablolar ise başka bir yol seçer:
1. sayfada son gördüğün takım 3 numaraydı; sonraki sayfa anahtar tabanlı olarak şöyle istenir:
SELECT id, ad FROM takimlar WHERE id > 3 ORDER BY id LIMIT 3;| id | ad |
|---|---|
| 4 | Kuzey Rüzgarı |
| 5 | Ateş Kuşları |
| 6 | Kurt Sürüsü |
2 numara silinse de sonuç değişmez, çünkü sorgu satır saymıyor, 3'ten büyük olanlarla ilgileniyor. Bu projede OFFSET ile devam ediyoruz; Ders 32'deki sayfa parametresini kullanan istemciler bozulmasın.
Arama: ILIKE ve kullanıcının yazdığı joker karakterler
Ders 32'de arama da dosyadaki dizide yapılıyordu: filter ile her takımın adına includes ile bakıyordun. Ders 34'teki ILIKE aynı işi veritabanında, büyük-küçük harfe bakmadan yapar; aranan sözcüğün iki yanına % koyup parametre olarak vereceksin. Arama hem sayıma hem sayfaya uygulanmalı; yoksa X-Toplam-Sayi aramayla uyuşmaz. Ders 32'nin son görevindeki "ara iki kez yazılırsa 400" denetimini de koru. Listeyi şöyle değiştir:
app.get("/api/takimlar", async (req, res) => { const { sayfa, boyut, hata } = sayfaBilgisi(req.query) if (hata) { return res.status(400).json({ hata }) } if (req.query.ara !== undefined && typeof req.query.ara !== "string") { return res.status(400).json({ hata: "ara yalnız bir kez yazılmalı" }) } const desen = `%${req.query.ara ?? ""}%` const sayim = await pool.query( "SELECT count(*)::int AS toplam FROM takimlar WHERE ad ILIKE $1", [desen] ) const { rows } = await pool.query( `SELECT t.id, t.ad, t.olusturulma, count(DISTINCT o.id)::int AS "oyuncuSayisi", count(DISTINCT ts.sampiyon_id)::int AS "sampiyonSayisi" FROM takimlar t LEFT JOIN oyuncular o ON o.takim_id = t.id LEFT JOIN takim_sampiyonlari ts ON ts.takim_id = t.id WHERE t.ad ILIKE $1 GROUP BY t.id ORDER BY t.id LIMIT $2 OFFSET $3`, [desen, boyut, (sayfa - 1) * boyut] ) res.set("X-Toplam-Sayi", sayim.rows[0].toplam) res.json(rows)})ara gelmezse desen %% olur: "herhangi bir şey". Böylece aramasız istek için ayrı bir sorgu yazmana gerek kalmadı. Yeni bir parametre ($1) en başa girdiği için LIMIT ve OFFSET artık $2 ve $3, dizide de desen en başta. Numaraları kaydırmayı unutmak çok sık yapılan bir hata:
argument of LIMIT must be type bigint, not type textNe zaman çıkar? Sorguya yeni bir parametre ekleyip öbürlerinin numaralarını kaydırmayı unutunca. Burada WHERE t.ad ILIKE $1 eklendi, ama sorgunun sonunda hâlâ LIMIT $1 OFFSET $2 yazıyor:
WHERE t.ad ILIKE $1 GROUP BY t.id ORDER BY t.id LIMIT $1 OFFSET $2`, [desen, boyut, (sayfa - 1) * boyut][8bcb376d] error: argument of LIMIT must be type bigint, not type text at C:\kod\takim-api\node_modules\pg-pool\index.js:45:11 at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async file:///C:/kod/takim-api/sunucu.js:79:22 { length: 139, severity: 'ERROR', code: '42804', ...İstemci yalnız 500 ve "Sunucuda bir hata oldu" görür. Mesaj kafa karıştırıcı: "LIMIT'in değeri bigint olmalı, text değil". $1 hem ILIKE'ta hem LIMIT'te kullanıldığı için Postgres onu ILIKE'a bakıp metin saydı ve metinle sayfa boyu olmaz dedi. Numaralar ile dizideki eleman sayısı tutmuyorsa başka bir mesaj görürsün: bind message supplies 3 parameters, but prepared statement "" requires 2, yani "3 değer gönderdin, sorgu 2 bekliyordu".
Nasıl düzelir? $ numaralarını dizideki sıraya göre baştan say: $1 dizinin ilk elemanı, $2 ikincisi. Sorgu metninde her numaranın doğru yeri gösterdiğinden emin ol.
Numaralar doğruyken:
PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/takimlar?ara=kurt"[{"id":1,"ad":"Gece Kurtları","olusturulma":"2026-09-01T18:30:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":3},{"id":6,"ad":"Kurt Sürüsü","olusturulma":"2026-09-12T21:10:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2}]PS C:\kod\takim-api> curl.exe -i "http://localhost:3000/api/takimlar?ara=kurt&boyut=1&sayfa=2"HTTP/1.1 200 OKX-Powered-By: ExpressX-Istek-Kimligi: 580a35acX-Toplam-Sayi: 2Content-Type: application/json; charset=utf-8Content-Length: 109ETag: W/"6d-X5yW7FES2HHcXA5bGblVDQDEegk"Date: Mon, 12 Oct 2026 00:33:07 GMTConnection: keep-aliveKeep-Alive: timeout=5 [{"id":6,"ad":"Kurt Sürüsü","olusturulma":"2026-09-12T21:10:00.000Z","oyuncuSayisi":2,"sampiyonSayisi":2}]Arama ve sayfalama birlikte çalışıyor: "kurt" geçen 2 takım var, sayfa boyu 1 olunca 2. sayfada Kurt Sürüsü geliyor. Türkçe harfli aramaları Ders 28'deki gibi Invoke-RestMethod ile at; curl.exe adresteki ş, ı gibi harfleri kodlamadan gönderir ve Node isteği 400 Bad Request ile geri çevirir. Ders 32'deki aramayla karşılaştır:
?ara=AVCINe zaman çıkar? Türkçe bir adı, içinde I olan büyük harflerle arayınca. Ders 32'deki API ?ara=AVCI ile Baron Avcıları'nı buluyordu; ILIKE'lı sürüm bulamıyor. Hata mesajı yok, liste boş:
PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=AVCI").Count0PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=avcı") | Select-Object id, ad id ad-- -- 3 Baron Avcıları ILIKE büyük-küçük harfi veritabanının dil ayarına göre eşleştirir (Ders 34). Neon'un varsayılan ayarı C.UTF-8: Ş ile ş'yi eşleştirir, ama I'nın küçüğünü ı değil i sayar. Ders 9'daki toLowerCase tuzağının veritabanındaki ikizi:
SELECT 'Baron Avcıları' ILIKE '%AVCI%' AS buyuk_harfle, 'Baron Avcıları' ILIKE '%avcı%' AS kucuk_harfle;| buyuk_harfle | kucuk_harfle |
|---|---|
| false | true |
Nasıl düzelir? Ders 32'deki gibi aranan metni önce JavaScript'te Türkçe kurallarla küçült: toLocaleLowerCase("tr"). AVCI, avcı olur; küçük harfle gelen desen ILIKE'ta doğru eşleşir. Hemen aşağıda deseni kuran fonksiyona ekleyeceksin.
Kullanıcının yazdığı % ve _
Arama kutusuna % yazan biri ne bulur? Invoke-RestMethod %'yi adrese doğru kodlar:
PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=%").Count9PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=_").Count9Adında yüzde işareti ya da alt çizgi olan takım yok, ama 9 takımın hepsi geldi. Desen %%% oldu ve Ders 34'teki joker karakter % "herhangi bir şey" demek; _ de "herhangi bir karakter". Bu bir güvenlik açığı değil: parametre kullandığımız için kimse sorguya SQL sokamaz. Ama kullanıcının yazdığı işaret, aramanın anlamını değiştiriyor.
Çare, kullanıcının yazdığı joker karakterleri kaçırmak: başlarına ters eğik çizgi koyup "bunu harfi harfine ara" demek. LIKE'ta \% gerçek bir yüzde işareti, \_ gerçek bir alt çizgi demektir. Farkı SQL Editor'de gör:
SELECT (SELECT count(*) FROM takimlar WHERE ad ILIKE '%%%') AS kacissiz, (SELECT count(*) FROM takimlar WHERE ad ILIKE '%\%%') AS kacisli;| kacissiz | kacisli |
|---|---|
| 9 | 0 |
Deseni kuran işi, Türkçe küçültmeyle birlikte bir fonksiyona taşı. Liste parametrelerini işleyen sayfaBilgisi zaten dogrula.js'te; yanına ekle:
export function aramaDeseni(ara) { const metin = (ara ?? "").trim().toLocaleLowerCase("tr") const kacisli = metin.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_") return `%${kacisli}%`}arahiç gelmediyse?? ""boş metinle devam ediyor;trimbaştaki ve sondaki boşlukları atıyor;toLocaleLowerCase("tr")TürkçeI'yııyapıyor.replaceAll(Ders 9) her joker karakterin önüne bir ters eğik çizgi koyuyor. JavaScript metninde ters eğik çizginin kendisi de kaçış işareti olduğu için tek bir\yazmak için"\\"yazılır.- İlk
replaceAllkullanıcının yazdığı ters eğik çizgileri ikiliyor. Önce onu yapmak zorundayız; yoksa sonradan eklediğimiz\işaretleri de ikilenirdi.
Fonksiyonu sunucuya bağlamadan önce tek başına dene:
import { aramaDeseni } from "./dogrula.js" console.log(aramaDeseni("kurt"))console.log(aramaDeseni(" AVCI "))console.log(aramaDeseni("%"))console.log(aramaDeseni("yüzde_50"))console.log(aramaDeseni(undefined))PS C:\kod\takim-api> node dogrula-deneme.js%kurt%%avcı%%\%%%yüzde\_50%%%Bu dosya DATABASE_URL istemediği için --env-file gerekmedi. Denedikten sonra dogrula-deneme.js'i sil.
sunucu.js'te içe aktarma satırına aramaDeseni'ni ekle ve deseni kuran satırı değiştir:
const desen = aramaDeseni(req.query.ara)PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=%").Count0PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=_").Count0PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=AVCI") | Select-Object id, ad id ad-- -- 3 Baron Avcıları PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara=KUŞ") | Select-Object id, ad id ad-- -- 5 Ateş Kuşları PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/takimlar?ara= gece ") | Select-Object id, ad id ad-- -- 1 Gece Kurtları Ders 32'deki aramanın bütün sınırları geri geldi, üstüne joker karakterler etkisiz. Bir sınır kaldı: adı büyük I ile yazılmış bir takım (IŞIK Takımı gibi) ışık diye arayınca bulunamaz, çünkü veritabanı o I'ları i sayar ve adı işik takımı gibi görür. Tam çözüm Türkçe dil kuralları (collation) gerektirir; bu kursun kapsamı dışında.
Takımı tek seferde kurmak: transaction
Geriye bir uç nokta kaldı: yeni takım. İstemci takımı oyuncuları ve şampiyonlarıyla birlikte, tek bir istekte gönderiyor; Ders 32'deki gövdenin aynısı. istekler klasörüne koy:
{ "ad": "Gece Baykuşları", "oyuncular": [ { "kullaniciAdi": "deniz", "rol": "orta" }, { "kullaniciAdi": "bora", "rol": "alt" } ], "sampiyonlar": ["Ahri", "Sona"]}Bu istek üç tabloya yazacak. Önce aklına ilk gelen yoldan, transaction'sız yaz. PATCH /api/takimlar/:id route'unun hemen üstüne:
app.post("/api/takimlar", async (req, res) => { const hata = takimHatasi(req.body, await havuzuGetir()) if (hata) { return res.status(400).json({ hata }) } const oyuncular = req.body.oyuncular ?? [] const sampiyonlar = req.body.sampiyonlar ?? [] const { rows } = await pool.query( "INSERT INTO takimlar (ad) VALUES ($1) RETURNING id", [req.body.ad.trim()] ) const yeniId = rows[0].id for (const oyuncu of oyuncular) { await pool.query( "INSERT INTO oyuncular (kullanici_adi, rol, takim_id) VALUES ($1, $2, $3)", [oyuncu.kullaniciAdi.trim(), oyuncu.rol, yeniId] ) } for (const ad of sampiyonlar) { const bulunan = await pool.query("SELECT id FROM sampiyonlar WHERE ad = $1", [ad]) await pool.query( "INSERT INTO takim_sampiyonlari (takim_id, sampiyon_id) VALUES ($1, $2)", [yeniId, bulunan.rows[0].id] ) } res.status(201).json(await takimGetir(yeniId))})- Gövdeyi Ders 32'deki
takimHatasidenetliyor: ad, en çok beş oyuncu, roller, en çok beş şampiyon, havuzda olmayan ya da iki kez yazılan şampiyon. Havuz artıkhavuzuGetirile tablodan geliyor. - Takım eklenir,
RETURNING idyeni numarayı verir; oyuncular ve bağlantılar o numarayla eklenir. Şampiyonlar adla geldiği için her birinin numarası tablodan bulunur;takimHatasiadların havuzda olduğunu zaten denetledi. - Sonunda yanıt olarak takımın tamamı,
takimGetirile.
Gönder:
PS C:\kod\takim-api> curl.exe -i --json "@istekler\yeni-takim.json" http://localhost:3000/api/takimlarHTTP/1.1 409 ConflictX-Powered-By: ExpressX-Istek-Kimligi: 26f5d5c4Content-Type: application/json; charset=utf-8Content-Length: 30ETag: W/"1e-kXBzyIO2NxxOzFRoHXnrwvr4rYA"Date: Mon, 12 Oct 2026 00:25:00 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"hata":"Bu kayıt zaten var"}409: deniz zaten Gece Kurtları'nda ve kullanıcı adında UNIQUE kuralı var. takimHatasi bunu yakalayamazdı; başka takımlardaki kullanıcı adlarını yalnız veritabanı biliyor. İstek reddedildi, öyleyse hiçbir şey olmadı... değil mi? Adında "bayku" geçen takımlara bak:
PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/takimlar?ara=bayku"[{"id":11,"ad":"Gece Baykuşları","olusturulma":"2026-10-12T00:25:00.504Z","oyuncuSayisi":0,"sampiyonSayisi":0}]Takım eklenmiş, ama oyuncusuz ve şampiyonsuz: yarım bir takım. İlk INSERT başarılı oldu ve kalıcı oldu, ikincisi (deniz) hata verdi ve gerisi hiç çalışmadı. İstemci 409 aldığı için takımın olmadığını sanıyor; düzeltip yeniden gönderdiğinde iki tane Gece Baykuşları olacak. Ders 35'teki "yoktan var olan altın"ın API'deki hâli.
Çözümü taşıma betiğinde zaten yazdın: pool.connect() ile bir bağlantı ayır, işi BEGIN ile COMMIT arasına al, hata olursa ROLLBACK, her durumda release(). POST route'unu şöyle değiştir:
app.post("/api/takimlar", async (req, res) => { const hata = takimHatasi(req.body, await havuzuGetir()) if (hata) { return res.status(400).json({ hata }) } const oyuncular = req.body.oyuncular ?? [] const sampiyonlar = req.body.sampiyonlar ?? [] const istemci = await pool.connect() let yeniId try { await istemci.query("BEGIN") const { rows } = await istemci.query( "INSERT INTO takimlar (ad) VALUES ($1) RETURNING id", [req.body.ad.trim()] ) yeniId = rows[0].id for (const oyuncu of oyuncular) { await istemci.query( "INSERT INTO oyuncular (kullanici_adi, rol, takim_id) VALUES ($1, $2, $3)", [oyuncu.kullaniciAdi.trim(), oyuncu.rol, yeniId] ) } for (const ad of sampiyonlar) { const bulunan = await istemci.query("SELECT id FROM sampiyonlar WHERE ad = $1", [ad]) await istemci.query( "INSERT INTO takim_sampiyonlari (takim_id, sampiyon_id) VALUES ($1, $2)", [yeniId, bulunan.rows[0].id] ) } await istemci.query("COMMIT") } catch (hata) { await istemci.query("ROLLBACK") throw hata } finally { istemci.release() } res.status(201).json(await takimGetir(yeniId))})- Gövdenin denetimi transaction'dan önce: bozuk bir istek için bağlantı ayırmaya değmez.
- Transaction içindeki her sorgu
istemci.query. Tek birpool.querykalsa o sorgu başka bir bağlantıdan, transaction'ın dışında çalışırdı. catchönceROLLBACKyapıyor, sonra hatayıthrow hataile yeniden fırlatıyor. Hatayı burada yutmuyoruz; Express 5 onu hata yakalayıcıya taşır, çeviri de her zamanki gibi 409'a ya da 400'e çevirir.let yeniIdtry'ın dışında, çünkü bloğun içinde açılan değişken dışarıdan görünmez (Ders 10) ve yanıtıtrybittikten sonra yazıyoruz.takimGetirCOMMIT'ten sonra çalışıyor vepool'u kullanıyor; takım artık kalıcı olduğu için hangi bağlantıdan bakılırsa bakılsın görünür.
Kaydet, aynı isteği yeniden gönder ve arkasından ara:
PS C:\kod\takim-api> curl.exe --json "@istekler\yeni-takim.json" http://localhost:3000/api/takimlar{"hata":"Bu kayıt zaten var"}PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/takimlar?ara=bayku"[{"id":11,"ad":"Gece Baykuşları","olusturulma":"2026-10-12T00:25:00.504Z","oyuncuSayisi":0,"sampiyonSayisi":0}]Aynı 409, ama bu kez yeni bir yarım takım eklenmedi; listede yalnız ilk denemenin bıraktığı 11 numara duruyor. Takım eklenmişti, deniz gelince geri alındı. O yarım takımı sil, gövdede deniz'i nil yap ve yeniden gönder:
PS C:\kod\takim-api> curl.exe -i -X DELETE http://localhost:3000/api/takimlar/11HTTP/1.1 204 No ContentX-Powered-By: ExpressX-Istek-Kimligi: 27d5e0a7Date: Mon, 12 Oct 2026 00:25:09 GMTConnection: keep-aliveKeep-Alive: timeout=5 PS C:\kod\takim-api> curl.exe -i --json "@istekler\yeni-takim.json" http://localhost:3000/api/takimlarHTTP/1.1 201 CreatedX-Powered-By: ExpressX-Istek-Kimligi: 033446f4Content-Type: application/json; charset=utf-8Content-Length: 191ETag: W/"bf-8xeP9AePBpIR1d6/KmCzcTML0s8"Date: Mon, 12 Oct 2026 00:25:09 GMTConnection: keep-aliveKeep-Alive: timeout=5 {"id":13,"ad":"Gece Baykuşları","olusturulma":"2026-10-12T00:25:09.129Z","oyuncular":[{"kullaniciAdi":"nil","rol":"orta"},{"kullaniciAdi":"bora","rol":"alt"}],"sampiyonlar":["Ahri","Sona"]}201 ve takımın tamamı. Numarası 13: geri alınan deneme 12'yi harcadı. Numaralar sende farklı olabilir; önemli olan boşluk değil, yarım takım kalmaması. Takımı silip CASCADE'in işini gör:
PS C:\kod\takim-api> curl.exe -X DELETE http://localhost:3000/api/takimlar/13PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/13{"hata":"Takım bulunamadı"}SELECT count(*) AS kalan FROM oyuncular WHERE kullanici_adi IN ('nil', 'bora');| kalan |
|---|
| 0 |
Tek bir DELETE takımı, iki oyuncusunu ve iki şampiyon bağlantısını götürdü. nil ve bora adları yeniden alınabilir.
Error: timeout exceeded when trying to connect: release() finally'de değilNe zaman çıkar? pool.connect() ile aldığın bağlantıyı bir yolda geri bırakmayı unutunca. Ders 37'de release()'in hiç yazılmadığı hâlini görmüştün. Daha sinsi biçimi, onu finally yerine try'ın sonuna yazmak: başarılı isteklerde çalışır, hatalı isteklerde hiç çalışmaz.
await istemci.query("COMMIT") istemci.release() } catch (hata) { await istemci.query("ROLLBACK") throw hata }Bu hâliyle denemek için yeni-takim.json'daki nil'i yeniden deniz yap; her istek hata alsın. Gövdeyi on kez gönder, sonra listeyi iste. Ders 37'deki gibi 1..10 | ForEach-Object { ... } süslü parantezin içini on kez çalıştırır; -o NUL gövdeyi atar, -w yalnız durum kodunu yazar:
PS C:\kod\takim-api> (1..10 | ForEach-Object { curl.exe -s -o NUL -w "%{http_code}" --json "@istekler\yeni-takim.json" http://localhost:3000/api/takimlar }) -join " "409 409 409 409 409 409 409 409 409 409PS C:\kod\takim-api> curl.exe -s -w " %{http_code} %{time_total}" http://localhost:3000/api/takimlar{"hata":"Sunucuda bir hata oldu","istekKimligi":"e6653fc4"} 500 5.017371[e6653fc4] Error: timeout exceeded when trying to connect at C:\kod\takim-api\node_modules\pg-pool\index.js:45:11 at async file:///C:/kod/takim-api/sunucu.js:75:19[e6653fc4] GET /api/takimlar 500 5016 msOn hatalı isteğin her biri bir bağlantı aldı ve geri vermedi. Havuz en çok 10 bağlantı açar; on birinci istek boş bağlantı bekledi, connectionTimeoutMillis: 5000 yüzünden 5 saniye sonra vazgeçti ve 500 döndü. Artık listeleme dahil veritabanına giden her istek aynı şekilde düşer: API'nin bütünü durdu. Başarılı istekler hiç sızdırmadığı için bu hata denemelerde gözden kaçar, yayında hatalı istekler biriktikçe ortaya çıkar.
Nasıl düzelir? release()'i finally'ye koy; finally başarıda da hatada da çalışır. Sunucuyu yeniden başlatmak havuzu sıfırlar ama hatayı düzeltmez.
Yayına almak
Geliştirme dalında her şey çalışıyor. Şimdi canlı veritabanını hazırlayacak ve Ders 32'de Render'a koyduğun API'yi ona bağlayacaksın. Kodu yayına alma yolun aynı kalıyor: git push ve Render'ın otomatik yayını.
Canlı veritabanı: production dalı
- Neon'da Connect'e bas, dal olarak
production'ı seç, Connection pooling'i kapat ve dizeyi kopyala. - Proje klasöründe
.env.canliadında ikinci bir dosya aç ve dizeyiDATABASE_URL=satırına yaz; yinesslmode=verify-fullile..gitignore'a.env.canlisatırını ekle. - Şemayı ve veriyi canlı dala bir kez gönder.
PS C:\kod\takim-api> node --env-file=.env.canli kur.jsŞema kurulduPS C:\kod\takim-api> node --env-file=.env.canli tasi.jsTaşındı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıHangi dosyanın hangi veritabanına gittiğini komuttaki --env-file belirliyor. Bu iki komutu canlıda yalnız bir kez, ilk kurulumda çalıştır: kur.js tabloları siler. Canlı tablolarını sonradan değiştirmen gerekirse Ders 37'deki göç dosyalarını yazarsın; görevlerde göreceksin.
Render'da DATABASE_URL
Render'da .env yok; ayarları servisin Environment sayfasından girdiğini Ders 32'den biliyorsun. Bu kez sıra önemli: yeni kod DATABASE_URL olmadan başlarsa db.js'teki sigorta programı durdurur ve yayın başarısız olur. Önce değişkeni gir, sonra kodu gönder:
- Neon'da Connect'e bas, dal olarak
production'ı seç ve bu kez Connection pooling anahtarını açık bırak. Adresinde-poolergeçen dizeyi kopyala ve sonunusslmode=verify-fullyap. Neon'un belgesi web uygulamaları için havuzlu dizeyi öneriyor: aynı anda gelen çok sayıda bağlantıyı Neon'un tarafında da bir havuzdan geçirir. - Render'da servisinin Environment sayfasına gir. + Add Environment Variable ile Key
DATABASE_URL, Value kopyaladığın dize.VERI_DOSYASI'yı girdiysen sil; artık bir işe yaramıyor. - Kaydet düğmesinin menüsünden Save only'yi seç. Değişken bir sonraki yayında devreye girecek.
- Kodu kaydedip
git pushyap (dersin sonundaki kayıt noktası). Render yeni sürümünpm installile derler,pg'yi kurar ve yayına alır.
Render'ın arayüzü değişmiş olabilir; düğmelerin adları Ders 32'dekilerle aynı. Ders 32'de Health Checks'e yazdığın /saglik artık veritabanını da yokluyor: Neon'a ulaşamayan yeni bir sürüm 500 döner ve Render trafiği ona geçirmez, eski sürüm yayında kalır. Canlı adresinde dene; XXXX yerine kendi adresini yaz:
curl.exe https://takim-api-XXXX.onrender.com/saglikcurl.exe "https://takim-api-XXXX.onrender.com/api/takimlar?boyut=2"İki uyku üst üste binebilir: Render'ın ücretsiz servisi 15 dakika istek almazsa uyur (Ders 32), Neon'un ücretsiz veritabanı 5 dakika sorgu almazsa. Uzun bir sessizlikten sonraki ilk istek bu yüzden yavaş gelir; sonrakiler hızlanır. Yanıt hiç gelmezse önce Render'ın Logs sayfasına bak: istek kimliğiyle hatanın satırını bulursun.
README
API'yi kullananlar için README'yi güncelle. Değişen bölümler şunlar; geri kalanı Ders 32'deki gibi kalsın:
Takımları, oyuncularını ve şampiyon havuzunu tutan küçük bir REST API. Express 5 ile yazıldı, veriyi PostgreSQL'de (Neon) saklar. ## Kurulum 1. `npm install`2. `.env` dosyası oluştur (aşağıdaki ayarlar)3. `npm run kur`: tabloları kurar. Var olan tabloları **siler**; yalnız boş bir veritabanında çalıştır.4. `npm run tasi`: `veri.json`'daki eski veriyi tablolara taşır. Tablolar doluysa hiçbir şey yapmaz.5. `npm run dev` ## Ayarlar | Değişken | Varsayılan | Ne işe yarar ||---|---|---|| `PORT` | `3000` | Sunucunun dinlediği port. Barındırıcı kendisi verir. || `DATABASE_URL` | (zorunlu) | PostgreSQL bağlantı dizesi, `sslmode=verify-full` ile || `SALT_OKUNUR` | (boş) | `evet` yazılırsa GET dışındaki istekler 503 alır | ## Uç noktalar | GET | `/api/takimlar?ara=&sayfa=&boyut=` | Takımları özet olarak listeler (`oyuncuSayisi`, `sampiyonSayisi`); `ara` adda arar, `sayfa` ve `boyut` sayfalar. Toplam sayı `X-Toplam-Sayi` başlığında | 200 || PATCH | `/api/sampiyonlar/:ad` | Şampiyonun adını değiştirir; takımlar yeni adı görür | 200 || DELETE | `/api/sampiyonlar/:ad` | Hiçbir takımda olmayan şampiyonu siler; kullanılıyorsa 409 | 204 || GET | `/saglik` | API ve veritabanı ayakta mı | 200 | ## Hatalar | 409 | Kayıt zaten var (kullanıcı adı başka takımda) ya da kayda bağlı başka kayıtlar var | ## Sınırlar Veri Neon'un ücretsiz planındaki PostgreSQL'de durur: sunucu yeniden başlasa da kaybolmaz. Veritabanı 5 dakika istek almazsa uyur; uyandıktan sonraki ilk istek biraz gecikebilir. Liste en yeni takımı sona koyar; çok büyük sayfa numaralarında yavaşlar.Uç nokta ve hata tablolarında yalnız değişen ya da eklenen satırları gösterdik; onları kendi tablolarındaki yerlerine koy.
Son hâl
sunucu.js'in tamamı. Kendi dosyanla karşılaştır:
import express from "express"import { randomUUID } from "node:crypto"import { pool } from "./db.js"import { adHatasi, takimHatasi, sampiyonHatasi, sayfaBilgisi, aramaDeseni } from "./dogrula.js"import { pgHatasiniCevir } from "./hatalar.js" const app = express()const port = process.env.PORT ?? 3000const saltOkunur = process.env.SALT_OKUNUR === "evet" app.use((req, res, next) => { req.kimlik = randomUUID().slice(0, 8) res.set("X-Istek-Kimligi", req.kimlik) next()}) app.use((req, res, next) => { const baslangic = Date.now() res.on("finish", () => { const sure = Date.now() - baslangic console.log(`[${req.kimlik}] ${req.method} ${req.originalUrl} ${res.statusCode} ${sure} ms`) }) next()}) app.use((req, res, next) => { if (saltOkunur && req.method !== "GET") { return res.status(503).json({ hata: "API şu an salt okunur" }) } next()}) app.use(express.json()) async function havuzuGetir() { const { rows } = await pool.query("SELECT ad FROM sampiyonlar ORDER BY ad") return rows.map((satir) => satir.ad)} async function takimGetir(id) { const takimSonucu = await pool.query( "SELECT id, ad, olusturulma FROM takimlar WHERE id = $1", [id] ) if (takimSonucu.rows.length === 0) { return null } const oyuncuSonucu = await pool.query( `SELECT kullanici_adi AS "kullaniciAdi", rol FROM oyuncular WHERE takim_id = $1 ORDER BY id`, [id] ) const sampiyonSonucu = await pool.query( `SELECT s.ad FROM takim_sampiyonlari ts JOIN sampiyonlar s ON s.id = ts.sampiyon_id WHERE ts.takim_id = $1 ORDER BY s.ad`, [id] ) return { ...takimSonucu.rows[0], oyuncular: oyuncuSonucu.rows, sampiyonlar: sampiyonSonucu.rows.map((satir) => satir.ad) }} app.get("/api/takimlar", async (req, res) => { const { sayfa, boyut, hata } = sayfaBilgisi(req.query) if (hata) { return res.status(400).json({ hata }) } if (req.query.ara !== undefined && typeof req.query.ara !== "string") { return res.status(400).json({ hata: "ara yalnız bir kez yazılmalı" }) } const desen = aramaDeseni(req.query.ara) const sayim = await pool.query( "SELECT count(*)::int AS toplam FROM takimlar WHERE ad ILIKE $1", [desen] ) const { rows } = await pool.query( `SELECT t.id, t.ad, t.olusturulma, count(DISTINCT o.id)::int AS "oyuncuSayisi", count(DISTINCT ts.sampiyon_id)::int AS "sampiyonSayisi" FROM takimlar t LEFT JOIN oyuncular o ON o.takim_id = t.id LEFT JOIN takim_sampiyonlari ts ON ts.takim_id = t.id WHERE t.ad ILIKE $1 GROUP BY t.id ORDER BY t.id LIMIT $2 OFFSET $3`, [desen, boyut, (sayfa - 1) * boyut] ) res.set("X-Toplam-Sayi", sayim.rows[0].toplam) res.json(rows)}) app.get("/api/takimlar/:id", async (req, res) => { const takim = await takimGetir(req.params.id) if (!takim) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.json(takim)}) app.post("/api/takimlar", async (req, res) => { const hata = takimHatasi(req.body, await havuzuGetir()) if (hata) { return res.status(400).json({ hata }) } const oyuncular = req.body.oyuncular ?? [] const sampiyonlar = req.body.sampiyonlar ?? [] const istemci = await pool.connect() let yeniId try { await istemci.query("BEGIN") const { rows } = await istemci.query( "INSERT INTO takimlar (ad) VALUES ($1) RETURNING id", [req.body.ad.trim()] ) yeniId = rows[0].id for (const oyuncu of oyuncular) { await istemci.query( "INSERT INTO oyuncular (kullanici_adi, rol, takim_id) VALUES ($1, $2, $3)", [oyuncu.kullaniciAdi.trim(), oyuncu.rol, yeniId] ) } for (const ad of sampiyonlar) { const bulunan = await istemci.query("SELECT id FROM sampiyonlar WHERE ad = $1", [ad]) await istemci.query( "INSERT INTO takim_sampiyonlari (takim_id, sampiyon_id) VALUES ($1, $2)", [yeniId, bulunan.rows[0].id] ) } await istemci.query("COMMIT") } catch (hata) { await istemci.query("ROLLBACK") throw hata } finally { istemci.release() } res.status(201).json(await takimGetir(yeniId))}) app.patch("/api/takimlar/:id", async (req, res) => { const hata = adHatasi(req.body?.ad) if (hata) { return res.status(400).json({ hata }) } const { rowCount } = await pool.query( "UPDATE takimlar SET ad = $1 WHERE id = $2", [req.body.ad.trim(), req.params.id] ) if (rowCount === 0) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.json(await takimGetir(req.params.id))}) app.delete("/api/takimlar/:id", async (req, res) => { const { rowCount } = await pool.query("DELETE FROM takimlar WHERE id = $1", [req.params.id]) if (rowCount === 0) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.status(204).end()}) app.get("/api/sampiyonlar", async (req, res) => { res.json(await havuzuGetir())}) app.post("/api/sampiyonlar", async (req, res) => { const hata = sampiyonHatasi(req.body?.ad, await havuzuGetir()) if (hata) { return res.status(400).json({ hata }) } const { rows } = await pool.query( "INSERT INTO sampiyonlar (ad) VALUES ($1) RETURNING ad", [req.body.ad.trim()] ) res.status(201).json(rows[0])}) app.patch("/api/sampiyonlar/:ad", async (req, res) => { const havuz = await havuzuGetir() const eskiAd = req.params.ad if (!havuz.includes(eskiAd)) { return res.status(404).json({ hata: "Şampiyon bulunamadı" }) } const hata = sampiyonHatasi(req.body?.ad, havuz) if (hata) { return res.status(400).json({ hata }) } const yeniAd = req.body.ad.trim() const { rows } = await pool.query( "UPDATE sampiyonlar SET ad = $1 WHERE ad = $2 RETURNING id", [yeniAd, eskiAd] ) const sayim = await pool.query( "SELECT count(*)::int AS sayi FROM takim_sampiyonlari WHERE sampiyon_id = $1", [rows[0].id] ) res.json({ eskiAd, yeniAd, takimSayisi: sayim.rows[0].sayi })}) app.delete("/api/sampiyonlar/:ad", async (req, res) => { const { rowCount } = await pool.query("DELETE FROM sampiyonlar WHERE ad = $1", [req.params.ad]) if (rowCount === 0) { return res.status(404).json({ hata: "Şampiyon bulunamadı" }) } res.status(204).end()}) app.get("/saglik", async (req, res) => { const { rows } = await pool.query("SELECT count(*)::int AS sayi FROM takimlar") res.json({ durum: "iyi", calismaSuresi: Math.floor(process.uptime()), surum: process.env.RENDER_GIT_COMMIT?.slice(0, 7) ?? "yerel", takimSayisi: rows[0].sayi })}) app.use((req, res) => { res.status(404).json({ hata: "Böyle bir uç nokta yok" })}) app.use((hata, req, res, next) => { if (hata.type === "entity.parse.failed") { return res.status(400).json({ hata: "Gövde geçerli bir JSON değil" }) } const cevap = pgHatasiniCevir(hata, req.method) if (cevap) { return res.status(cevap.durum).json({ hata: cevap.mesaj }) } console.error(`[${req.kimlik}]`, hata) res.status(500).json({ hata: "Sunucuda bir hata oldu", istekKimligi: req.kimlik })}) app.listen(port, (hata) => { if (hata) { throw hata } console.log(`Takım API'si çalışıyor: http://localhost:${port}`) if (saltOkunur) { console.log("Salt okunur: GET dışındaki istekler 503 alacak") }})246 satır: dört tabloya yazan, sayfalayan, arayan, hatalarını doğru koda çeviren ve yarım iş bırakmayan bir API. Ders 32'deki dosyadan uzun değil; değişen, verinin nerede durduğu.
Görevler
Görevler projeyi büyütüyor; her biri bir öncekinin bıraktığı yerden devam ediyor. Sunucu npm run dev ile açık kalsın, istekleri ikinci terminalden at, SQL'i gelistirme dalında çalıştır. Yeni route'ları 404 ara katmanının üstüne yaz. Önce kendin dene, takılırsan ipuçlarını sırayla aç.
Kuru çalıştırma
Canlıda taşıma yapmadan önce betiğin ne yapacağını görmek istersin. tasi.js'e bir --kuru seçeneği ekle: verilince bütün işi yapsın ama sonunda COMMIT yerine ROLLBACK desin ve şunu yazsın:
Kuru çalıştırma, hiçbir şey yazılmadı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıSeçenek verilmezse eskisi gibi taşısın. Denemek için tabloları npm run kur ile sıfırla (verin taşımayla yeniden gelecek), kuru çalıştırmayı iki kez üst üste dene, sonra gerçek taşımayı yap.
İpucu 1
process.argv, programı başlatan komut satırındaki sözcüklerin dizisidir. node --env-file=.env tasi.js --kuru yazınca dizinin sonunda "--kuru" olur; process.argv.includes("--kuru") bunu sorar. npm run ile çalıştırırken betiğe sözcük geçirmek için araya -- koyarsın: npm run tasi -- --kuru.
İpucu 2
COMMIT satırını await istemci.query(kuru ? "ROLLBACK" : "COMMIT") yap. Hangi mesajın yazılacağına da aynı kuru karar versin.
Çözümü göster
const veri = JSON.parse(await readFile("veri.json", "utf8"))const kuru = process.argv.includes("--kuru") await istemci.query(kuru ? "ROLLBACK" : "COMMIT") const ozet = `${veri.sampiyonlar.length} şampiyon, ${veri.takimlar.length} takım, ${oyuncuSayisi} oyuncu, ${baglantiSayisi} bağlantı` console.log(kuru ? `Kuru çalıştırma, hiçbir şey yazılmadı: ${ozet}` : `Taşındı: ${ozet}`)PS C:\kod\takim-api> npm run kur > takim-api@1.0.0 kur> node --env-file=.env kur.js Şema kurulduPS C:\kod\takim-api> npm run tasi -- --kuru > takim-api@1.0.0 tasi> node --env-file=.env tasi.js --kuru Kuru çalıştırma, hiçbir şey yazılmadı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıPS C:\kod\takim-api> node --env-file=.env tasi.js --kuruKuru çalıştırma, hiçbir şey yazılmadı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıİkinci kuru çalıştırma da sigortaya takılmadı: ilki gerçekten hiçbir şey yazmamıştı. Ama bir iz bıraktı. Hemen npm run tasi ile taşısaydın şampiyonlar 1'den değil 29'dan başlayarak numaralanırdı (denedik: 29'dan 42'ye), çünkü iki kuru çalıştırma sayaçtan 14'er numara harcadı ve ROLLBACK sayacı geri almaz. Bu yüzden gerçek taşımadan önce bir kez daha npm run kur çalıştır:
PS C:\kod\takim-api> npm run kur > takim-api@1.0.0 kur> node --env-file=.env kur.js Şema kurulduPS C:\kod\takim-api> npm run tasi > takim-api@1.0.0 tasi> node --env-file=.env tasi.js Taşındı: 14 şampiyon, 9 takım, 15 oyuncu, 18 bağlantıTablolar baştan kurulduğu için Gölge Ordusu yeniden Gölge Takımı oldu; sonraki görevlerde bu adla göreceksin. Canlıda ise kuru çalıştırmanın numara harcaması zararsız: boşluklar kimliği bozmaz.
Oyuncu dizini
GET /api/oyuncular oyuncuları kullanıcı adına göre sıralayıp takımlarının adıyla birlikte listelesin. Liste anahtar tabanlı sayfalansın:
boyut: sayfadaki oyuncu sayısı; varsayılan 10, en çok 50.sonra: istemcinin bir önceki sayfada gördüğü son kullanıcı adı. Verilmezse liste baştan başlar.ara: verilirse yalnız kullanıcı adında bu metin geçen oyuncular gelsin. Büyük-küçük harf fark etmesin;%ve_harfi harfine aransın.
PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?boyut=3"[{"kullaniciAdi":"arda","rol":"üst","takim":"Kuzey Rüzgarı"},{"kullaniciAdi":"burak","rol":"destek","takim":"Kristal Kalkan"},{"kullaniciAdi":"cem","rol":"orta","takim":"Ateş Kuşları"}]PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?boyut=3&sonra=cem"[{"kullaniciAdi":"defne","rol":"alt","takim":"Kristal Kalkan"},{"kullaniciAdi":"deniz","rol":"orta","takim":"Gece Kurtları"},{"kullaniciAdi":"ece","rol":"destek","takim":"Gece Kurtları"}]Sınırları da dene: ?ara=E&boyut=3 ve aynı aramanın sonraki sayfası; son oyuncudan sonrası (?sonra=zeynep boş dizi); ?ara=% (boş dizi); ?boyut=51 (400); iki kez yazılan sonra (400).
İpucu 1
Liste route'undaki parçaları yeniden kullan: sayfaBilgisi boyut'u denetler (sayfa'yı bu uçta kullanmayacaksın), aramaDeseni deseni kurar. Takımın adı için oyuncuyu takimlar'a JOIN ile bağla; kullanici_adi'yı tırnaklı takma adla kullaniciAdi yap.
İpucu 2
Anahtar tabanlı sayfalamada OFFSET yok: WHERE o.kullanici_adi > $2 "son gördüğümden sonrakiler" demek, ORDER BY o.kullanici_adi de aynı sütuna göre sıralar. sonra gelmezse boş metin kullan; her ad boş metinden "büyüktür". Sıra benzersiz bir sütuna dayanmalı: kullanici_adi UNIQUE olduğu için uygun.
İpucu 3
Dizi [desen, sonra, boyut], sorguda $1, $2, $3. ?sonra=a&sonra=b gibi iki kez yazılan bir parametre dizi olarak gelir (Ders 32'nin son görevi); ara'yı denetlediğin gibi sonra'nın da metin olduğuna bak.
Çözümü göster
app.get("/api/oyuncular", async (req, res) => { const { boyut, hata } = sayfaBilgisi(req.query) if (hata) { return res.status(400).json({ hata }) } const ara = req.query.ara const sonra = req.query.sonra ?? "" if ((ara !== undefined && typeof ara !== "string") || typeof sonra !== "string") { return res.status(400).json({ hata: "ara ve sonra yalnız bir kez yazılmalı" }) } const { rows } = await pool.query( `SELECT o.kullanici_adi AS "kullaniciAdi", o.rol, t.ad AS takim FROM oyuncular o JOIN takimlar t ON t.id = o.takim_id WHERE o.kullanici_adi ILIKE $1 AND o.kullanici_adi > $2 ORDER BY o.kullanici_adi LIMIT $3`, [aramaDeseni(ara), sonra, boyut] ) res.json(rows)})PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?ara=E&boyut=3"[{"kullaniciAdi":"cem","rol":"orta","takim":"Ateş Kuşları"},{"kullaniciAdi":"defne","rol":"alt","takim":"Kristal Kalkan"},{"kullaniciAdi":"deniz","rol":"orta","takim":"Gece Kurtları"}]PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?ara=E&boyut=3&sonra=deniz"[{"kullaniciAdi":"ece","rol":"destek","takim":"Gece Kurtları"},{"kullaniciAdi":"elif","rol":"destek","takim":"Ateş Kuşları"},{"kullaniciAdi":"emre","rol":"alt","takim":"Son Vuruş"}]PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?sonra=zeynep"[]PS C:\kod\takim-api> (Invoke-RestMethod "http://localhost:3000/api/oyuncular?ara=%").Count0PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?boyut=51"{"hata":"boyut 1 ile 50 arasında bir tam sayı olmalı"}PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?sonra=a&sonra=b"{"hata":"ara ve sonra yalnız bir kez yazılmalı"}Büyük E ile aradık ama ILIKE ve aramaDeseni sayesinde e geçen bütün adlar geldi; ikinci sayfa, birincinin son adı olan deniz'den sonra devam etti. sonra'daki adın tabloda olması gerekmiyor: ?boyut=2&sonra=d "d'den sonra gelen adlar" demek ve defne ile deniz'i getirir. Sayfalar sayıyla değil adla ilerlediği için sen 1. sayfayı okurken burak silinse bile 2. sayfa yine defne ile başlar; OFFSET'te o arada bir oyuncu gözden kaçardı.
Bir şeyi bil: > ve ORDER BY adları veritabanının dil ayarıyla karşılaştırır. C.UTF-8 harfleri kod numarasına göre dizer; ömer gibi Türkçe harfle başlayan bir ad zeynep'ten sonra gelir. Sayfalama yine doğru çalışır, çünkü sıralama ile karşılaştırma aynı kuralı kullanıyor; yalnız sıra alfabetik görünmez.
İstatistik uç noktası
GET /api/istatistik şunu döndürsün: toplam takım ve oyuncu sayısı, en çok takımda seçilen üç şampiyon ve hiçbir takımda olmayan şampiyonlar:
PS C:\kod\takim-api> curl.exe http://localhost:3000/api/istatistik{"takimSayisi":9,"oyuncuSayisi":15,"enCokSecilenler":[{"ad":"Lux","takimSayisi":3},{"ad":"Ahri","takimSayisi":2},{"ad":"Annie","takimSayisi":2}],"hicSecilmeyenler":["Morgana","Sona","Vi","Zed"]}Altı şampiyon 2 takımla berabere. Eşitlikte sıra adla, A'dan Z'ye belirlensin; her çağrıda aynı sonuç gelsin.
İpucu 1
Sorguları önce SQL Editor'de kur. En çok seçilenler için ara tabloyu şampiyonlara bağla, ada göre grupla ve say. Hiç seçilmeyenler için Ders 36'daki LEFT JOIN: eşi olmayan satırların ara tablo sütunları NULL'dur.
İpucu 2
ORDER BY "takimSayisi" DESC tek başına yetmez: eşit sayılı şampiyonların sırası belirsizdir. Sıralamaya ikinci bir ölçüt ekle: s.ad. Sayıların metin olarak gelmemesi için ::int'i unutma.
Çözümü göster
SQL Editor'de en çok seçilen üç şampiyon:
SELECT s.ad, count(*)::int AS "takimSayisi"FROM takim_sampiyonlari tsJOIN sampiyonlar s ON s.id = ts.sampiyon_idGROUP BY s.adORDER BY "takimSayisi" DESC, s.adLIMIT 3;| ad | takimSayisi |
|---|---|
| Lux | 3 |
| Ahri | 2 |
| Annie | 2 |
Hiçbir takımda olmayanlar:
SELECT s.adFROM sampiyonlar sLEFT JOIN takim_sampiyonlari ts ON ts.sampiyon_id = s.idWHERE ts.sampiyon_id IS NULLORDER BY s.ad;| ad |
|---|
| Morgana |
| Sona |
| Vi |
| Zed |
Uç nokta üç sorguyu birleştiriyor:
app.get("/api/istatistik", async (req, res) => { const sayilar = await pool.query( `SELECT (SELECT count(*)::int FROM takimlar) AS "takimSayisi", (SELECT count(*)::int FROM oyuncular) AS "oyuncuSayisi"` ) const enCok = await pool.query( `SELECT s.ad, count(*)::int AS "takimSayisi" FROM takim_sampiyonlari ts JOIN sampiyonlar s ON s.id = ts.sampiyon_id GROUP BY s.ad ORDER BY "takimSayisi" DESC, s.ad LIMIT 3` ) const hicSecilmeyen = await pool.query( `SELECT s.ad FROM sampiyonlar s LEFT JOIN takim_sampiyonlari ts ON ts.sampiyon_id = s.id WHERE ts.sampiyon_id IS NULL ORDER BY s.ad` ) res.json({ ...sayilar.rows[0], enCokSecilenler: enCok.rows, hicSecilmeyenler: hicSecilmeyen.rows.map((satir) => satir.ad) })})Eşitlik bozucu olmadan denediğimizde, sıralama yalnız sayıya göreyken Lux'tan sonra Ahri ve Jinx geldi: Postgres eşitlerin sırasını garanti etmez, o anki çalışma düzenine göre verir. s.ad eklenince sonuç her seferinde aynı. Sınır durumu: hiç takım yoksa enCokSecilenler boş bir dizi, hicSecilmeyenler bütün şampiyonlar olur; ikisi de hata vermez.
Benzersiz takım adı
Şu an iki takım aynı adı alabiliyor; kurt sürüsü gibi yalnız harf büyüklüğü farklı bir ad da. Takım adlarını büyük-küçük harfe bakmadan benzersiz yap. Kuralı bir göç dosyasıyla ekle: Ders 37'deki goc.js'i bu projeye kopyala, goclar klasörünü aç ve ilk göçü yaz. Aynı adla takım kurmaya ya da bir takımın adını başka bir takımınkine çevirmeye kalkan istek şunu alsın:
409 {"hata":"Bu takım adı alınmış"}Önce sorunu gör: SQL Editor'de INSERT INTO takimlar (ad) VALUES ('kurt sürüsü'); çalıştır. Postgres buna izin veriyor. Kopya dururken göçü çalıştırmayı dene ve hata mesajını oku.
İpucu 1
UNIQUE büyük-küçük harfe duyarlıdır (Ders 35). Büyük-küçük harfe bakmadan benzersizlik için sütunun kendisine değil lower(ad)'a benzersiz bir indeks aç: CREATE UNIQUE INDEX takimlar_ad_benzersiz ON takimlar (lower(ad)); Bir indeks, sütunun yanında bir ifade üzerine de kurulabilir.
İpucu 2
Kural, tabloda ona uymayan satırlar dururken eklenemez; önce kopyayı sil. goc.js bozuk bir göçü ROLLBACK ile geri aldığı ve goc_gecmisi'ne yazmadığı için onu düzeltip yeniden çalıştırmak serbest.
İpucu 3
Çeviri 23505'i zaten 409'a çeviriyor; yalnız mesajı özelleştirmen gerekiyor. hatalar.js'e kısıt adlarını mesajlara eşleyen bir obje ekle ve hata.constraint'e bak. Kısıt adı sunucuda kalır, istemciye yalnız Türkçe cümle gider.
Çözümü göster
Kopya dururken:
INSERT INTO takimlar (ad) VALUES ('kurt sürüsü');CREATE UNIQUE INDEX takimlar_ad_benzersiz ON takimlar (lower(ad));PS C:\kod\takim-api> node --env-file=.env goc.jsC:\kod\takim-api\node_modules\pg\lib\client.js:694 Error.captureStackTrace(err) ^ error: could not create unique index "takimlar_ad_benzersiz" at C:\kod\takim-api\node_modules\pg\lib\client.js:694:17 at process.processTicksAndRejections (node:internal/process/task_queues:104:5) at async file:///C:/kod/takim-api/goc.js:23:9 { length: 231, severity: 'ERROR', code: '23505', detail: 'Key (lower(ad))=(kurt sürüsü) is duplicated.', ... constraint: 'takimlar_ad_benzersiz', ...} Node.js v24.20.0Kopyayı sil, göçü yeniden çalıştır:
DELETE FROM takimlar WHERE ad = 'kurt sürüsü';PS C:\kod\takim-api> node --env-file=.env goc.jsUygulandı: 001_takim_adi_benzersiz.sqlGöçler tamam.hatalar.js'in en üstüne kısıt mesajlarını ekle ve 23505 dalını değiştir:
const KISIT_MESAJLARI = { takimlar_ad_benzersiz: "Bu takım adı alınmış", oyuncular_kullanici_adi_key: "Bu kullanıcı adı başka bir takımda"} export function pgHatasiniCevir(hata, metot) { if (hata.code === "23505") { return { durum: 409, mesaj: KISIT_MESAJLARI[hata.constraint] ?? "Bu kayıt zaten var" } }istekler klasöründeki gövdeler: ayni-ad.json { "ad": "KURT SÜRÜSÜ" }, ayni-ad-bosluk.json { "ad": " kurt sürüsü " }, kendi-adi.json { "ad": "Kurt Sürüsü" }, yakin-ad.json { "ad": "Kurt Sürüsü 2" }, alinmis-oyuncu.json { "ad": "Sabah Kuşları", "oyuncular": [{ "kullaniciAdi": "deniz", "rol": "orta" }] }. -w " %{http_code}" gövdenin sonuna durum kodunu ekler:
PS C:\kod\takim-api> curl.exe -w " %{http_code}" --json "@istekler\ayni-ad.json" http://localhost:3000/api/takimlar{"hata":"Bu takım adı alınmış"} 409PS C:\kod\takim-api> curl.exe -w " %{http_code}" --json "@istekler\ayni-ad-bosluk.json" http://localhost:3000/api/takimlar{"hata":"Bu takım adı alınmış"} 409PS C:\kod\takim-api> curl.exe -w " %{http_code}" -X PATCH --json "@istekler\kendi-adi.json" http://localhost:3000/api/takimlar/1{"hata":"Bu takım adı alınmış"} 409PS C:\kod\takim-api> curl.exe -w " %{http_code}" -X PATCH --json "@istekler\kendi-adi.json" http://localhost:3000/api/takimlar/6{"id":6,"ad":"Kurt Sürüsü","olusturulma":"2026-09-12T21:10:00.000Z","oyuncular":[{"kullaniciAdi":"ozan","rol":"üst"},{"kullaniciAdi":"irem","rol":"orman"}],"sampiyonlar":["Teemo","Yasuo"]} 200PS C:\kod\takim-api> curl.exe -w " %{http_code}" --json "@istekler\yakin-ad.json" http://localhost:3000/api/takimlar{"id":14,"ad":"Kurt Sürüsü 2","olusturulma":"2026-10-12T01:52:56.454Z","oyuncular":[],"sampiyonlar":[]} 201PS C:\kod\takim-api> curl.exe -w " %{http_code}" --json "@istekler\alinmis-oyuncu.json" http://localhost:3000/api/takimlar{"hata":"Bu kullanıcı adı başka bir takımda"} 409Sınırlar: baştaki ve sondaki boşluk trim ile atıldığı için kopyayı gizleyemedi; Gece Kurtları'nın adını Kurt Sürüsü yapmak 409 aldı; Kurt Sürüsü'nü kendi adına güncellemek ise serbest, çünkü o ad zaten onun. Benzer ama farklı bir ad kabul edildi. Kullanıcı adı çakışması da artık kendi cümlesiyle dönüyor. Yeni takım neden 14? SQL Editor'de eklediğin kopya 11'i, reddedilen iki istek de 12 ile 13'ü harcadı: INSERT önce sayaçtan numarayı alır, benzersizlik denetimi sonra gelir. Numaralar sende farklı olabilir; denemeden sonra 14'ü sil.
Bir sınırı bilerek bıraktık. golge.json dosyasına { "ad": "GÖLGE TAKIMI" } yazıp gönder: bizde 201 ile kabul edildi ve 16 numarayı aldı. lower I'yı ı değil i yaptığı için gölge takimi ile gölge takımı farklı metinler; Türkçe I yine karşında. Kuralın sınırını gördükten sonra bu takımı da sil.
Şampiyon havuzunu değiştir
PUT /api/takimlar/:id/sampiyonlar takımın bütün şampiyon listesini yenisiyle değiştirsin. Gövde { "sampiyonlar": ["Ahri", "Zed"] }; yanıt takımın güncel hâli. Kurallar:
- Listede havuzda olmayan bir şampiyon varsa 400 (
{"hata":"Zedd şampiyon havuzunda yok"}) ve takımın eski listesi hiç değişmeden kalsın. - Takım yoksa 404.
sampiyonlaren çok 5 elemanlı bir dizi değilse 400. - Boş liste geçerli: takımın hiç şampiyonu kalmaz.
- Aynı ad iki kez yazılırsa bir kez sayılsın.
İpucu 1
"Hepsini değiştir" iki adım: takımın bütün bağlantılarını sil, yenilerini ekle. İkinci adımın ortasında bir ad bulunamazsa ilk adım geri alınmalı; bu bir transaction işi. POST'taki kalıbı kullan. Tekrarları tasi.js'teki gibi bir adlar dizisiyle ayıkla.
İpucu 2
Takımın varlığını transaction'ın içinde, ilk iş olarak sor (SELECT id FROM takimlar WHERE id = $1). Yoksa await istemci.query("ROLLBACK") yap ve return res.status(404)... ile çık. try içinden return etsen de finally çalışır ve bağlantı geri verilir. Silme: DELETE FROM takim_sampiyonlari WHERE takim_id = $1.
İpucu 3
PUT, Ders 26'daki gibi "kaynağın tamamını yenisiyle değiştir" demek; liste bu yüzden PATCH değil PUT ile gidiyor.
Çözümü göster
app.put("/api/takimlar/:id/sampiyonlar", async (req, res) => { const sampiyonlar = req.body?.sampiyonlar if (!Array.isArray(sampiyonlar) || sampiyonlar.length > 5) { return res.status(400).json({ hata: "sampiyonlar en çok 5 elemanlı bir dizi olmalı" }) } const adlar = [] for (const ad of sampiyonlar) { if (!adlar.includes(ad)) { adlar.push(ad) } } const istemci = await pool.connect() try { await istemci.query("BEGIN") const takim = await istemci.query("SELECT id FROM takimlar WHERE id = $1", [req.params.id]) if (takim.rows.length === 0) { await istemci.query("ROLLBACK") return res.status(404).json({ hata: "Takım bulunamadı" }) } await istemci.query("DELETE FROM takim_sampiyonlari WHERE takim_id = $1", [req.params.id]) for (const ad of adlar) { const bulunan = await istemci.query("SELECT id FROM sampiyonlar WHERE ad = $1", [ad]) if (bulunan.rows.length === 0) { await istemci.query("ROLLBACK") return res.status(400).json({ hata: `${ad} şampiyon havuzunda yok` }) } await istemci.query( "INSERT INTO takim_sampiyonlari (takim_id, sampiyon_id) VALUES ($1, $2)", [req.params.id, bulunan.rows[0].id] ) } await istemci.query("COMMIT") } catch (hata) { await istemci.query("ROLLBACK") throw hata } finally { istemci.release() } res.json(await takimGetir(req.params.id))})istekler klasöründeki gövdeler: havuz.json { "sampiyonlar": ["Ahri", "Zed", "Ahri"] }, havuz-hatali.json { "sampiyonlar": ["Sona", "Zedd"] }, havuz-bos.json { "sampiyonlar": [] }, havuz-dizi-degil.json { "sampiyonlar": "Ahri" }.
PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz.json" http://localhost:3000/api/takimlar/10/sampiyonlar{"id":10,"ad":"Gölge Takımı","olusturulma":"2026-10-01T08:00:00.000Z","oyuncular":[],"sampiyonlar":["Ahri","Zed"]}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz-hatali.json" http://localhost:3000/api/takimlar/10/sampiyonlar{"hata":"Zedd şampiyon havuzunda yok"}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/10{"id":10,"ad":"Gölge Takımı","olusturulma":"2026-10-01T08:00:00.000Z","oyuncular":[],"sampiyonlar":["Ahri","Zed"]}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz.json" http://localhost:3000/api/takimlar/7/sampiyonlar{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz.json" http://localhost:3000/api/takimlar/abc/sampiyonlar{"hata":"Gönderilen değerlerden biri geçersiz"}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz-dizi-degil.json" http://localhost:3000/api/takimlar/10/sampiyonlar{"hata":"sampiyonlar en çok 5 elemanlı bir dizi olmalı"}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz-bos.json" http://localhost:3000/api/takimlar/10/sampiyonlar{"id":10,"ad":"Gölge Takımı","olusturulma":"2026-10-01T08:00:00.000Z","oyuncular":[],"sampiyonlar":[]}Kanıt üçüncü satırda: Zedd hatasından sonra takımın listesi hâlâ ["Ahri","Zed"]. DELETE çalışmıştı, Sona eklenmişti; ikisi de geri alındı. Tekrar eden Ahri bir kez sayıldı. abc hatası catch'e düştü, geri alındı ve çeviri onu 400 yaptı. Son istek Gölge Takımı'nı başladığı yere, şampiyonsuz hâline döndürdü.
Bağlantı sızdırmadığını da dene: havuz-hatali.json'u on iki kez gönderip listeyi iste. Bizde on iki 400'ün ardından liste 0,004 saniyede 200 döndü; return'den sonra da finally bağlantıyı geri vermişti.
Yumuşak silme
Silinen takım geri gelmiyor; yanlışlıkla silinen bir takımın bütün oyuncuları da CASCADE ile gidiyor. Yumuşak silme (soft delete) dene: DELETE takımı gerçekten silmesin, ne zaman silindiğini bir sütuna yazsın. Silinmiş takım hiçbir yerde görünmesin:
- İkinci bir göç dosyasıyla
takimlar'asilinmeadında, boş (NULL) olabilen birtimestamptzsütunu ekle.NULL"silinmedi" demek. DELETEbu sütuna şimdiki zamanı yazsın. Zaten silinmiş bir takımı silmek 404 versin.- Liste, sayım, tek takım,
PATCH,PUT .../sampiyonlarve/sagliksilinmiş takımları görmesin; Görev 2'deki oyuncu dizini de silinmiş takımların oyuncularını göstermesin. - İstatistikteki takım ve oyuncu sayısı ile en çok ve hiç seçilmeyenler silinmiş takımları saymasın.
- Silinmiş bir takımın adı yeni bir takıma verilebilsin.
İpucu 1
Göç dosyası goclar/002_yumusak_silme.sql: ALTER TABLE takimlar ADD COLUMN silinme timestamptz; Var olan takımlarda değer NULL olur. DELETE route'u: UPDATE takimlar SET silinme = now() WHERE id = $1 AND silinme IS NULL. rowCount 0 ise 404. 001 numaralı göçe dokunma; uygulanmış göç değiştirilmez (Ders 37).
İpucu 2
Takımı okuyan her sorguya silinme IS NULL koşulunu ekle. İstatistikte ara tabloyu takimlar'a bağlaman gerekecek. Hiç seçilmeyenler sorgusunda koşulu WHERE'e değil ikinci LEFT JOIN'in ON'una yaz (Ders 36) ve HAVING count(t.id) = 0 ile "hiçbir canlı takımda yok" de.
İpucu 3
Görev 4'teki indeks silinmiş takımların adlarını da tutuyor. Bir indeks yalnız bazı satırlar için kurulabilir: sonuna WHERE silinme IS NULL yazarsan kural yalnız silinmemiş takımlara uygulanır. Aynı göç dosyasında indeksi DROP INDEX ile sil ve koşullu hâliyle yeniden kur.
Çözümü göster
ALTER TABLE takimlar ADD COLUMN silinme timestamptz;DROP INDEX takimlar_ad_benzersiz;CREATE UNIQUE INDEX takimlar_ad_benzersiz ON takimlar (lower(ad)) WHERE silinme IS NULL;PS C:\kod\takim-api> node --env-file=.env goc.jsUygulandı: 002_yumusak_silme.sqlGöçler tamam.sunucu.js'te değişen satırlar:
// takimGetir, ilk sorgu:"SELECT id, ad, olusturulma FROM takimlar WHERE id = $1 AND silinme IS NULL", // liste, sayım:"SELECT count(*)::int AS toplam FROM takimlar WHERE ad ILIKE $1 AND silinme IS NULL", // liste, sayfa sorgusunun WHERE satırı: WHERE t.ad ILIKE $1 AND t.silinme IS NULL // PATCH:"UPDATE takimlar SET ad = $1 WHERE id = $2 AND silinme IS NULL", // PUT .../sampiyonlar, takımın varlığı:"SELECT id FROM takimlar WHERE id = $1 AND silinme IS NULL", // Görev 2, oyuncu dizininin WHERE satırı: WHERE o.kullanici_adi ILIKE $1 AND o.kullanici_adi > $2 AND t.silinme IS NULL // /saglik:"SELECT count(*)::int AS sayi FROM takimlar WHERE silinme IS NULL"DELETE route'u:
app.delete("/api/takimlar/:id", async (req, res) => { const { rowCount } = await pool.query( "UPDATE takimlar SET silinme = now() WHERE id = $1 AND silinme IS NULL", [req.params.id] ) if (rowCount === 0) { return res.status(404).json({ hata: "Takım bulunamadı" }) } res.status(204).end()})İstatistiğin üç sorgusu:
const sayilar = await pool.query( `SELECT (SELECT count(*)::int FROM takimlar WHERE silinme IS NULL) AS "takimSayisi", (SELECT count(*)::int FROM oyuncular o JOIN takimlar t ON t.id = o.takim_id WHERE t.silinme IS NULL) AS "oyuncuSayisi"` ) const enCok = await pool.query( `SELECT s.ad, count(*)::int AS "takimSayisi" FROM takim_sampiyonlari ts JOIN sampiyonlar s ON s.id = ts.sampiyon_id JOIN takimlar t ON t.id = ts.takim_id WHERE t.silinme IS NULL GROUP BY s.ad ORDER BY "takimSayisi" DESC, s.ad LIMIT 3` ) const hicSecilmeyen = await pool.query( `SELECT s.ad FROM sampiyonlar s LEFT JOIN takim_sampiyonlari ts ON ts.sampiyon_id = s.id LEFT JOIN takimlar t ON t.id = ts.takim_id AND t.silinme IS NULL GROUP BY s.ad HAVING count(t.id) = 0 ORDER BY s.ad` )Son sorguda bağlantısı olan ama yalnız silinmiş takımlarda geçen şampiyon için t.id NULL kalır; count(t.id) onu saymaz ve şampiyon "hiç seçilmeyen" sayılır. Son Vuruş'u (9) sil ve sınırları dene; son-vurus.json { "ad": "Son Vuruş" }:
PS C:\kod\takim-api> curl.exe -s -o NUL -w "%{http_code}" -X DELETE http://localhost:3000/api/takimlar/9204PS C:\kod\takim-api> curl.exe -X DELETE http://localhost:3000/api/takimlar/9{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe http://localhost:3000/api/takimlar/9{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe -X PATCH --json "@istekler\son-vurus.json" http://localhost:3000/api/takimlar/9{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe -X PUT --json "@istekler\havuz.json" http://localhost:3000/api/takimlar/9/sampiyonlar{"hata":"Takım bulunamadı"}PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?ara=emre"[]PS C:\kod\takim-api> curl.exe http://localhost:3000/api/istatistik{"takimSayisi":8,"oyuncuSayisi":14,"enCokSecilenler":[{"ad":"Lux","takimSayisi":3},{"ad":"Annie","takimSayisi":2},{"ad":"Garen","takimSayisi":2}],"hicSecilmeyenler":["Morgana","Sona","Vi","Zed"]}PS C:\kod\takim-api> curl.exe http://localhost:3000/saglik{"durum":"iyi","calismaSuresi":10,"surum":"yerel","takimSayisi":8}Ahri bir takımını kaybettiği için ilk üçten düştü. Baron Avcıları'nı (3) da silince Lee Sin yalnız silinmiş bir takımda kalır:
PS C:\kod\takim-api> curl.exe -s -o NUL -w "%{http_code}" -X DELETE http://localhost:3000/api/takimlar/3204PS C:\kod\takim-api> curl.exe http://localhost:3000/api/istatistik{"takimSayisi":7,"oyuncuSayisi":12,"enCokSecilenler":[{"ad":"Lux","takimSayisi":3},{"ad":"Annie","takimSayisi":2},{"ad":"Garen","takimSayisi":2}],"hicSecilmeyenler":["Lee Sin","Morgana","Sona","Vi","Zed"]}PS C:\kod\takim-api> curl.exe "http://localhost:3000/api/oyuncular?boyut=4&sonra=ipek"[{"kullaniciAdi":"irem","rol":"orman","takim":"Kurt Sürüsü"},{"kullaniciAdi":"mert","rol":"alt","takim":"Mavi Ejderler"},{"kullaniciAdi":"ozan","rol":"üst","takim":"Kurt Sürüsü"},{"kullaniciAdi":"selin","rol":"orman","takim":"Mavi Ejderler"}]PS C:\kod\takim-api> curl.exe --json "@istekler\son-vurus.json" http://localhost:3000/api/takimlar{"id":17,"ad":"Son Vuruş","olusturulma":"2026-10-12T01:53:20.255Z","oyuncular":[],"sampiyonlar":[]}PS C:\kod\takim-api> curl.exe --json "@istekler\son-vurus.json" http://localhost:3000/api/takimlar{"hata":"Bu takım adı alınmış"}Oyuncu dizininde irem'den sonra kaan yok: takımı silindi. Silinmiş Son Vuruş'un adıyla yeni bir takım kuruldu; aynı adla ikinci kez kurmak ise yasak, çünkü yeni takım silinmemiş. Silinen bir takımı geri getirmek bir UPDATE kadar kolay: UPDATE takimlar SET silinme = NULL WHERE id = 3; Yalnız aynı ada sahip silinmemiş bir takım varsa indeks buna izin vermez; 9'u geri getirmeyi denersen:
UPDATE takimlar SET silinme = NULL WHERE id = 9;ERROR: duplicate key value violates unique constraint "takimlar_ad_benzersiz"DETAIL: Key (lower(ad))=(son vuruş) already exists.Bir sınırı da bil: silinmiş takımların şampiyon bağlantıları ara tabloda duruyor. Lee Sin artık hiçbir canlı takımda yok, ama DELETE /api/sampiyonlar/Lee%20Sin hâlâ 409 alır; RESTRICT silinmiş takımları da sayar. Silinmiş takımların oyuncuları da tabloda duruyor: dizinde görünmeyen emre'yi yeni bir takıma yazmak isteyen istek {"hata":"Bu kullanıcı adı başka bir takımda"} alır. Kalıcı temizlik, ayrı ve bilinçli bir iş olarak yapılır.
Kendini sına
Soru 1
Bu sorguyla gelen JSON'da oyuncu sayısı hangi adla görünür, değeri sayı mı metin mi?
const { rows } = await pool.query( "SELECT t.ad, count(o.id) AS oyuncuSayisi FROM takimlar t LEFT JOIN oyuncular o ON o.takim_id = t.id GROUP BY t.id")res.json(rows)Cevabı göster
Ad oyuncusayisi olur: takma ad tırnaksız yazıldığı için Postgres onu küçük harfe çevirdi. Değer de "2" gibi bir metin: count bigint döndürür ve pg onu metin olarak verir. Doğrusu count(o.id)::int AS "oyuncuSayisi"; ikisi de Ders 37'deki tuzaklar.
Soru 2
Taşımadan hemen sonra yeni takım eklemeye çalıştın ve şunu aldın. Sorun veride mi? Ne yaparsın?
ERROR: duplicate key value violates unique constraint "takimlar_pkey"DETAIL: Key (id)=(1) already exists.Cevabı göster
Veride değil, sayaçta. Takımlar OVERRIDING SYSTEM VALUE ile kendi numaralarını getirdi; kimlik sütununun sayacı hiç ilerlemedi ve 1'i vermeye kalktı. SELECT setval(pg_get_serial_sequence('takimlar', 'id'), max(id)) FROM takimlar; sayacı en büyük numaraya getirir. Aynı satır taşıma betiğinin sonunda da olmalı.
Soru 3
GET /api/takimlar?sayfa=3&boyut=4 isteğinde OFFSET kaç olur? Tabloda 9 takım varsa X-Toplam-Sayi kaçtır, bu sayfada kaç takım gelir ve istemci son sayfayı nasıl bulur?
Cevabı göster
OFFSET (3 - 1) * 4 = 8. X-Toplam-Sayi 9: sayım sorgusu sayfaya değil bütün tabloya (aramaya uyan satırlara) bakar. 3. sayfa ilk 8 takımı atlar ve geriye kalan tek takımı getirir: son sayfa eksik dolu olabilir. İstemci sayfa sayısını Math.ceil(9 / 4) = 3 diye hesaplar; sayfa=4 boş bir dizi getirir, hata vermez.
Soru 4
Bu kod neden yanlış? İlk denemede çalışıyor gibi görünebilir.
await pool.query("BEGIN")await pool.query("INSERT INTO takimlar (ad) VALUES ($1)", [ad])await pool.query("INSERT INTO oyuncular (kullanici_adi, rol, takim_id) VALUES ($1, $2, $3)", [k, r, id])await pool.query("COMMIT")Cevabı göster
Transaction bir bağlantıya aittir, pool.query ise her komutu o an boşta olan herhangi bir bağlantıya verir. BEGIN bir bağlantıda açılıp INSERT'ler başka bağlantılarda, transaction'ın dışında çalışabilir; o zaman ROLLBACK hiçbir şeyi geri almaz. Tek başına denerken havuz çoğu zaman aynı bağlantıyı yeniden verdiği için sorun görünmez; aynı anda çok istek gelince ortaya çıkar. Doğrusu pool.connect() ile bir istemci almak, bütün komutları istemci.query ile göndermek ve finally'de release() çağırmak.
Soru 5
Hangi istek hangi durum kodunu almalı, kararı kim veriyor? (a) Başka takımda kullanılan bir kullanıcı adıyla takım kurmak. (b) /api/takimlar/abc. (c) Tek harfli bir takım adı. (d) İki takımın kullandığı bir şampiyonu silmek. (e) Olmayan bir takımı silmek.
Cevabı göster
(a) 409: UNIQUE ihlali, kod 23505; çeviri karar verir. (b) 400: "abc" sayıya çevrilemedi, kod 22P02; çeviri. (c) 400: dogrula.js'teki adHatasi istek veritabanına gitmeden karar verir; veritabanındaki CHECK (23514) arkadaki ikinci duvar. (d) 409: RESTRICT ihlali, PostgreSQL 18'de kod 23001; bu dersteki eklemeyle çeviri. (e) 404: hata yok, DELETE'in rowCount'u 0; kararı uç nokta veriyor.
Soru 6
takim_sampiyonlari tablosunda takim_id için ON DELETE CASCADE, sampiyon_id için ON DELETE RESTRICT seçtik. Neden ikisi farklı?
Cevabı göster
Bağlantı satırı takıma aittir: takım yoksa "bu takımda Lux var" satırının bir anlamı kalmaz, takımla birlikte gitmesi doğru (CASCADE). Şampiyon ise birçok takımın kullandığı ortak bir kayıt: biri onu silerse o takımların kadroları sessizce eksilirdi. RESTRICT bunu engeller; şampiyonu silmek isteyen önce onu kullanan takımlardan çıkarmak zorunda kalır. Ders 32'de aynı kuralı kodda, kullananlar dizisiyle yazmıştın; şimdi veritabanı tutuyor.
Soru 7
kur.js'i canlı veritabanında, API birkaç hafta yayında kaldıktan sonra yanlışlıkla çalıştırırsan ne olur? Bunu nasıl önlersin?
Cevabı göster
schema.sql'in ilk satırları dört tabloyu da siler; takımlar, oyuncular ve bağlantılar gider, tablolar boş olarak yeniden kurulur. Önlemler: canlı bağlantı dizesini ayrı bir dosyada (.env.canli) tut ve onu yalnız ilk kurulumda kullan; canlıda tablo değiştirmek için schema.sql'e değil numaralı göç dosyalarına yaz (Ders 37); büyük bir işten önce Ders 35'teki gibi Neon'da snapshot al. Kaza olduysa Neon'un geçmişten geri yükleme özelliği ücretsiz planda son 6 saati kurtarabilir.
Kayıt noktası
Blok 5'in projesi bitti. .env ve .env.canli'nin listede görünmediğine dikkat et; ikisi de .gitignore'da:
Sıradaki derste JavaScript'e tip ekleyen TypeScript'e geçiyorsun: bu derste "2"'nin sayı sanıldığı, kullaniciadi'nin undefined döndüğü türden hataları kod daha çalışmadan yakalamayı öğreneceksin.