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

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.

  • 150–210 dk
  • Önce: Ders 37
  • Proje dersi

Bu dersin sonunda

  • İç içe bir JSON dosyasını tablolara, sütunlara ve ilişkilere ayıran bir şema tasarlayabilir, onu schema.sql olarak 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/OFFSET ile, aramayı ILIKE ile 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_URL ile Neon'daki canlı veritabanına bağlayıp Render'da yayına alabilirsin
Bu sayfada
  1. Plan: dışarıdan aynı, içeriden yeni
  2. Başlangıç: veri.json
  3. Şema tasarımı
  4. Ayrı bir Neon projesi ve geliştirme dalı
  5. Veriyi taşımak: tasi.js
  6. Kimlik sayacı: taşımanın gizli tuzağı
  7. Uç noktaları SQL'e çevirmek
  8. Veritabanı hatalarını HTTP'ye çevirmek
  9. Sayfalama: LIMIT ve OFFSET
  10. Arama: ILIKE ve kullanıcının yazdığı joker karakterler
  11. Takımı tek seferde kurmak: transaction
  12. Yayına almak
  13. Görevler
  14. Kendini sına
  15. 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'deBu dersin sonunda
Veri neredeveri.json; Render'da yeniden başlayınca kaybolurPostgreSQL (Neon); kalıcı
GET /api/takimlarara, sayfa, boyut; takımlar tam hâliyle, toplam X-Toplam-Sayi başlığındaaynı parametreler, aynı başlık; takımlar özet bilgiyle
GET /api/takimlar/:idtek takımaynı JSON
POST /api/takimlartakımı dosyaya eklertakımı, oyuncularını ve şampiyonlarını tek transaction'da kurar
PATCH, DELETEdosyayı değiştiriraynı adresler, aynı yanıtlar
Şampiyon uç noktalarıad değişince bütün takımları dolaşırad tek bir satırda değişir
Hatalar400, 404, 500, 503ayrı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:

  1. Şema: dört tablo, schema.sql ve onu çalıştıran kur.js.
  2. Taşıma: veri.json'daki takımları temizleyerek tablolara aktaran tasi.js.
  3. Uç noktalar: her uç nokta dosya yerine SQL ile çalışacak, JSON biçimi korunacak.
  4. Hata çevirisi: Ders 37'deki çeviriyi getirip bu projede çıkan yeni kodları ekleyeceksin.
  5. Liste: sayfalama ve arama dosyadaki dizide değil, veritabanında yapılacak.
  6. Takım kurmak: takım, oyuncular ve şampiyonlar tek transaction'da.
  7. Yayına alma: Neon'da canlı veritabanı, Render'da DATABASE_URL.

Dersin sonunda klasörün şöyle görünecek:

C:\kod\takim-api
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övdeleri

Baş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.

veri.json
{  "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:

Ders 32'deki API, değişiklik yapmadan önce
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:

  1. Şeyleri bul. Dosyada üç tür şey var: takım, oyuncu, şampiyon. Her biri bir tablo olacak: takimlar, oyuncular, sampiyonlar.
  2. Listeleri dışarı çıkar. Takımın oyuncular listesi ayrı bir tabloya gidiyor; her oyuncu satırı hangi takıma ait olduğunu takim_id ile söylüyor. Bir oyuncu tek takımda, bir takımda çok oyuncu: bire-çok ilişki.
  3. Ç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ız sampiyonlar tablosunda, tek bir yerde duruyor.
  4. Adları çevir. JSON'daki kullaniciAdi, tabloda snake_case kuralıyla kullanici_adi oluyor. JSON'a geri dönerken takma adla camelCase'e çevireceksin; Ders 37'deki çift tırnak kuralı burada da geçerli.
veri.json'daki bir takım { "id": 3, "ad": "Baron Avcıları", "oyuncular": [ { "kullaniciAdi": "kaan", "rol": "orman" }, { "kullaniciAdi": "zeynep", "rol": "üst" } ], "sampiyonlar": ["Yasuo", "Lee Sin"] } takimlar idad 3Baron Avcıları oyuncular kullanici_adiroltakim_id kaanorman3 zeynepüst3 sampiyonlar idad 6Lee Sin 13Yasuo takim_sampiyonlari takim_idsampiyon_id 313 36
Şekil 38.1 Bir takımın JSON'u dört tabloya dağılıyor. Takımın kendi alanları 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:

PK birincil anahtar FK yabancı anahtar 1 … N bir satıra çok satır bağlanır takimlar idPK ad olusturulma sampiyonlar idPK ad oyuncular idPK kullanici_adi rol takim_idFK takim_sampiyonlari takim_idFK sampiyon_idFK PK: ikisi birlikte 1 N CASCADE 1 N CASCADE 1 N RESTRICT çoka-çok, ara tablo ile
Şekil 38.2 Takım API'sinin dört tablosu. 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:

SoruKararNeden
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 CASCADEBu 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 RESTRICTBir takımda kullanılan şampiyon silinemesin; Ders 32'deki "kullanılıyor, silinemez" kuralı artık veritabanında
Aynı kullanıcı adı iki kez?UNIQUEBir oyuncu iki takımda olamaz. Ders 32'de bu kural yoktu; yeni bir kural.
Takım adı kuralı?CHECK ile 2–40 karakterdogrula.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:

schema.sql
-- 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ı: DROP satırları, başka tabloyu gösteren tablolardan başlıyor. Ters sırada takimlar'ı önce silmeye kalksaydın Ders 36'daki cannot drop table ... because other objects depend on it hatasını alırdın.
  • char_length(ad) metnin karakter sayısını verir; BETWEEN 2 AND 40 iki 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_id her takım ayrıntısında aranacak. Ara tablonun bileşik anahtarı takim_id ile başladığı için o sütundaki aramalar zaten hızlı; sampiyon_id ile arama için ayrı bir indeks açtık. "Lux hangi takımlarda?" sorusu bu sütunla sorulur; bir şampiyon silinmek istendiğinde RESTRICT de "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.

  1. Neon'da New Project'e bas. Ad olarak takim-api yaz, 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'daki RESTRICT notu). 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.
  2. Sol menüden Branches sayfasına gir ve New branch'e bas. Parent branch production kalsın, dalın adına gelistirme yaz, veri olarak Current data'yı seç.
  3. 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.
  4. Ü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.

.env
PORT=3000DATABASE_URL=postgresql://neondb_owner:SIFREN@ep-ornek-123456.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=require

Ders 32'deki projede pg yoktu; kur:

Terminal — PowerShell
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 vulnerabilities

Bağlantı havuzunu Ders 37'deki gibi tek bir dosyada kur. C:\kod\pg-api\db.js'i bu projeye kopyala; içi şu olmalı:

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

Şimdi schema.sql'i veritabanına gönderen küçük betik. Proje klasöründe kur.js:

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:

package.json
  "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.

kur.js — takim-api — Visual Studio Code
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()
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
PS C:\kod\takim-api> npm run kur > takim-api@1.0.0 kur> node --env-file=.env kur.js Şema kuruldu
⎇ mainLn 1, Col 1Spaces: 4UTF-8JavaScript

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

SQL
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ı:

  1. veri.json'u oku.
  2. takimlar tablosu doluysa dur: taşıma iki kez yapılmasın.
  3. Bir transaction aç. Ya her şey taşınsın ya hiçbir şey.
  4. Şampiyonları ekle ve her birinin aldığı numarayı not et: ad → id.
  5. Her takımı eski numarasıyla ekle; ardından oyuncularını ve şampiyon bağlantılarını.
  6. Hata yoksa COMMIT, varsa ROLLBACK.

Üçü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.

GET /takimlar pool.query GET /takimlar/1 pool.query POST /takimlar pool.connect() Havuz (Pool) 1: kısa sorgu 2: kısa sorgu 3: POST'a ayrıldı BEGIN → INSERT… → COMMIT release(): havuza geri 4: boşta en çok 10 bağlantı Postgres Neon
Şekil 38.3 Kısa sorgular boştaki herhangi bir bağlantıyı kullanır. Transaction ise 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:

tasi.js
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 komut istemci.query ile, yani bu bağlantıdan gidiyor. pool.query ile BEGIN göndermek işe yaramaz: BEGIN bir bağlantıya, sonraki INSERT başka bir bağlantıya düşebilir.
  • count(*)::int: Ders 37'deki tuzak. count bigint döndürür, pg onu metin olarak verir ('9'); ::int gerçek bir sayı yapar.
  • sampiyonIdleri: anahtarı şampiyonun adı, değeri veritabanının verdiği id olan 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ın ROLLBACK ç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:

Terminal — PowerShell
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:

SQL
SELECT (SELECT count(*) FROM sampiyonlar) AS sampiyon,       (SELECT count(*) FROM takimlar) AS takim,       (SELECT count(*) FROM oyuncular) AS oyuncu;
sampiyontakimoyuncu
000

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).

tasi.js
const veri = JSON.parse(await readFile("veri.json", "utf8")) function anahtar(ad) {    return ad.trim().toLowerCase()} const istemci = await pool.connect()
tasi.js
        sampiyonIdleri[anahtar(ad)] = sonuc.rows[0].id
tasi.js
        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:

Terminal — PowerShell
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.
Sık hataduplicate 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:

tasi.js
        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şı:

Terminal — PowerShell
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:

Terminal — PowerShell
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ı:

SQL
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;
sampiyontakimoyuncubaglanti
1491518

İki kusurlu takımın şampiyonlarına da bak:

SQL
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;
takimsampiyon
Kristal KalkanGaren
Kristal KalkanLux
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:

SQL
INSERT INTO takimlar (ad) VALUES ('Deneme Takımı');
Hata çıktısı
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.

takimlar.id: taşımayla elle verildi 1 2 3 4 5 6 7 8 9 10 11 setval'den önce sayacın son verdiği: yok sıradaki: 1 → dolu, çakışma setval'den sonra sayacın son verdiği: 10 sıradaki: 11 → boş, sorun yok
Şekil 38.4 Taşıma eski numaraları elle yazdı, sayaç yerinde kaldı. 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:

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

tasi.js
    await istemci.query(        "SELECT setval(pg_get_serial_sequence('takimlar', 'id'), max(id)) FROM takimlar"    )    await istemci.query("COMMIT")
Sık hata409 aldım ama böyle bir takım yok: 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 ilePostgreSQL ile
hepsini listeleveri.takimlarSELECT ... FROM takimlar
tek takımı bulveri.takimlar.find(...)SELECT ... WHERE id = $1
eklepush, sonId + 1, writeFileINSERT ... RETURNING id
adını değiştirfind, atama, writeFileUPDATE ... SET ad = $1 WHERE id = $2
silsome, filter, writeFileDELETE ... 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:

sunucu.js: en üst
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ç:

Terminal — PowerShell
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:3000

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

sunucu.js: express.json()'ı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 null dö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 sessizce kullaniciadi olur (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; map onu 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:

Ders 32: veri.json
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)})
Ders 38: PostgreSQL
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:

İkinci terminal
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:

SQL
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;
idadoyuncusampiyon
1Gece Kurtları66
9Son Vuruş22

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

SQL
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;
idadoyuncusampiyon
1Gece Kurtları23
9Son Vuruş12
10Gölge Takımı00

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:

sunucu.js: liste route'u
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)})
İkinci terminal
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:

sunucu.js: PATCH ve DELETE
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. rowCount kaç satırın değiştiğini ya da silindiğini söyler; 0 ise öyle bir takım yoktu (Ders 37).
  • PATCH eskisi gibi takımın tamamını döndürüyor; sözleşme bunu söylüyordu. UPDATE'ten sonra takımı takimGetir ile yeniden okuyoruz.
  • Takımı silince ON DELETE CASCADE onun oyuncularını ve şampiyon bağlantılarını da siler. Bir DELETE üç 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:

İkinci terminal
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:

sunucu.js: 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:

sunucu.js: şampiyon uç noktaları
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ü. takimSayisi yanı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 RESTRICT o 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:

İkinci terminal
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:

sunucu.js: sağlık uç noktası
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    })})
İkinci terminal
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:

İkinci terminal
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ı:

Sunucunun terminali
[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 ms

Postgres "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.

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

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

sunucu.js: hata yakalayıcı
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:

İkinci terminal
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:

Sık hataKullanılan şampiyonu silmek 409 değil 500 veriyor: violates RESTRICT setting of foreign key constraint

Ne zaman çıkar? ON DELETE RESTRICT ile korunan bir satırı API'den silmeye kalkınca, hata çevirin yalnız 23503'ü tanıyorsa.

İkinci terminal
PS C:\kod\takim-api> curl.exe -X DELETE http://localhost:3000/api/sampiyonlar/Annie{"hata":"Sunucuda bir hata oldu","istekKimligi":"3e939229"}
Sunucunun terminali
[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 ms

Kural 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.

İkinci terminal
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:

hatalar.js
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"))
Çıktı
{ 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ı' }null

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

İkinci terminal
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:

SQL
UPDATE takimlar SET ad = 'G' WHERE id = 10;
Hata çıktısı
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:

sunucu.js: liste route'u
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)})
  • sayfaBilgisi Ders 32'deki gibi sayfa ve boyut'u doğruluyor: varsayılanlar 1 ve 10, boyut en çok 50. Sınırsız bırakırsan biri boyut=1000000 yazıp sayfalamayı boşa çıkarır.
  • (sayfa - 1) * boyut atlanacak takım sayısı: 3. sayfa ve boyut 4 ise ilk 8 takım atlanır.
  • LIMIT ve OFFSET'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 count sorgusu var; sonuç yine X-Toplam-Sayi baş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.
İkinci terminal
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:

SQL
SELECT id, ad FROM takimlar ORDER BY id LIMIT 3 OFFSET 3;
idad
4Kuzey Rüzgarı
5Ateş Kuşları
6Kurt Sürüsü

Şimdi 2 numaralı takım silinmiş gibi yap ve aynı sayfayı yeniden iste. Sondaki ROLLBACK silmeyi geri alır:

SQL
BEGIN;DELETE FROM takimlar WHERE id = 2;SELECT id, ad FROM takimlar ORDER BY id LIMIT 3 OFFSET 3;ROLLBACK;
idad
5Ateş Kuşları
6Kurt Sürüsü
8Kristal 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.

Önce 2 silindikten sonra 1. sayfa 1 Gece Kurtları 2 Mavi Ejderler 3 Baron Avcıları 2. sayfa 4 Kuzey Rüzgarı 5 Ateş Kuşları 6 Kurt Sürüsü 1 Gece Kurtları 3 Baron Avcıları 4 Kuzey Rüzgarı 5 Ateş Kuşları 6 Kurt Sürüsü 8 Kristal Kalkan 4 hiç görünmedi
Şekil 38.5 2 numaralı takım silinince arkasındaki her takım bir sıra yukarı kayar. Aynı 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:

SQL
SELECT id, ad FROM takimlar WHERE id > 3 ORDER BY id LIMIT 3;
idad
4Kuzey Rüzgarı
5Ateş Kuşları
6Kurt 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:

sunucu.js: liste route'u
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:

Sık hataargument of LIMIT must be type bigint, not type text

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

sunucu.js: liste route'unun sonu✗ Yanlış
         WHERE t.ad ILIKE $1         GROUP BY t.id         ORDER BY t.id         LIMIT $1 OFFSET $2`,        [desen, boyut, (sayfa - 1) * boyut]
Sunucunun terminali
[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:

İkinci terminal
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:

Sık hataBüyük harfle arama boş dönüyor: ?ara=AVCI

Ne 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ş:

İkinci terminal
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:

SQL
SELECT 'Baron Avcıları' ILIKE '%AVCI%' AS buyuk_harfle,       'Baron Avcıları' ILIKE '%avcı%' AS kucuk_harfle;
buyuk_harflekucuk_harfle
falsetrue

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:

İkinci terminal
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=_").Count9

Adı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:

SQL
SELECT (SELECT count(*) FROM takimlar WHERE ad ILIKE '%%%') AS kacissiz,       (SELECT count(*) FROM takimlar WHERE ad ILIKE '%\%%') AS kacisli;
kacissizkacisli
90

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:

dogrula.js
export function aramaDeseni(ara) {    const metin = (ara ?? "").trim().toLocaleLowerCase("tr")    const kacisli = metin.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")    return `%${kacisli}%`}
  • ara hiç gelmediyse ?? "" boş metinle devam ediyor; trim baştaki ve sondaki boşlukları atıyor; toLocaleLowerCase("tr") Türkçe I'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 replaceAll kullanı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:

dogrula-deneme.js — takim-api — Visual Studio Code
dogrula-deneme.js
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))
PROBLEMSOUTPUTDEBUG CONSOLETERMINAL
PS C:\kod\takim-api> node dogrula-deneme.js%kurt%%avcı%%\%%%yüzde\_50%%%
⎇ mainLn 1, Col 1Spaces: 4UTF-8JavaScript

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:

sunucu.js: liste route'unda
    const desen = aramaDeseni(req.query.ara)
İkinci terminal
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:

istekler\yeni-takim.json
{    "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:

sunucu.js: ilk deneme, transaction'sız
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 takimHatasi denetliyor: ad, en çok beş oyuncu, roller, en çok beş şampiyon, havuzda olmayan ya da iki kez yazılan şampiyon. Havuz artık havuzuGetir ile tablodan geliyor.
  • Takım eklenir, RETURNING id yeni numarayı verir; oyuncular ve bağlantılar o numarayla eklenir. Şampiyonlar adla geldiği için her birinin numarası tablodan bulunur; takimHatasi adların havuzda olduğunu zaten denetledi.
  • Sonunda yanıt olarak takımın tamamı, takimGetir ile.

Gönder:

İkinci terminal
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:

İkinci terminal
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:

sunucu.js: POST, transaction ile
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 bir pool.query kalsa o sorgu başka bir bağlantıdan, transaction'ın dışında çalışırdı.
  • catch önce ROLLBACK yapıyor, sonra hatayı throw hata ile 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 yeniId try'ın dışında, çünkü bloğun içinde açılan değişken dışarıdan görünmez (Ders 10) ve yanıtı try bittikten sonra yazıyoruz.
  • takimGetir COMMIT'ten sonra çalışıyor ve pool'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:

İkinci terminal
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:

İkinci terminal
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:

İkinci terminal
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ı"}
SQL
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.

Sık hataError: timeout exceeded when trying to connect: release() finally'de değil

Ne 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.

sunucu.js: POST route'unun sonu✗ Yanlış
        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:

İkinci terminal
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
Sunucunun terminali
[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 ms

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

  1. Neon'da Connect'e bas, dal olarak production'ı seç, Connection pooling'i kapat ve dizeyi kopyala.
  2. Proje klasöründe .env.canli adında ikinci bir dosya aç ve dizeyi DATABASE_URL= satırına yaz; yine sslmode=verify-full ile. .gitignore'a .env.canli satırını ekle.
  3. Şemayı ve veriyi canlı dala bir kez gönder.
Terminal — PowerShell
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:

  1. Neon'da Connect'e bas, dal olarak production'ı seç ve bu kez Connection pooling anahtarını açık bırak. Adresinde -pooler geçen dizeyi kopyala ve sonunu sslmode=verify-full yap. 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.
  2. 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.
  3. Kaydet düğmesinin menüsünden Save only'yi seç. Değişken bir sonraki yayında devreye girecek.
  4. Kodu kaydedip git push yap (dersin sonundaki kayıt noktası). Render yeni sürümü npm install ile 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:

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

README.md
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:

sunucu.js
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ç.

Görev 1

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:

Metin
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
tasi.js
const veri = JSON.parse(await readFile("veri.json", "utf8"))const kuru = process.argv.includes("--kuru")
tasi.js
    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}`)
Terminal — PowerShell
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:

Terminal — PowerShell
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.

Görev 2

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.
Terminal — PowerShell
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
sunucu.js: 404 ara katmanının üstüne
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)})
Terminal — PowerShell
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.

Görev 3

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

Terminal — PowerShell
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:

SQL
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;
adtakimSayisi
Lux3
Ahri2
Annie2

Hiçbir takımda olmayanlar:

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

sunucu.js: 404 ara katmanının üstüne
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.

Görev 4

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:

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

SQL
INSERT INTO takimlar (ad) VALUES ('kurt sürüsü');
goclar/001_takim_adi_benzersiz.sql
CREATE UNIQUE INDEX takimlar_ad_benzersiz ON takimlar (lower(ad));
Terminal — PowerShell
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.0

Kopyayı sil, göçü yeniden çalıştır:

SQL
DELETE FROM takimlar WHERE ad = 'kurt sürüsü';
Terminal — PowerShell
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:

hatalar.js
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:

Terminal — PowerShell
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"} 409

Sı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.

Görev 5

Ş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. sampiyonlar en ç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
sunucu.js: 404 ara katmanının üstüne
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" }.

Terminal — PowerShell
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.

Görev 6

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'a silinme adında, boş (NULL) olabilen bir timestamptz sütunu ekle. NULL "silinmedi" demek.
  • DELETE bu sütuna şimdiki zamanı yazsın. Zaten silinmiş bir takımı silmek 404 versin.
  • Liste, sayım, tek takım, PATCH, PUT .../sampiyonlar ve /saglik silinmiş 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
goclar/002_yumusak_silme.sql
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;
Terminal — PowerShell
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:

sunucu.js: değişen sorgular
// 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:

sunucu.js: DELETE
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:

sunucu.js: /api/istatistik
    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ş" }:

Terminal — PowerShell
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:

Terminal — PowerShell
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:

SQL
UPDATE takimlar SET silinme = NULL WHERE id = 9;
Hata çıktısı
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?

JavaScript
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?

Hata çıktısı
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.

JavaScript
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.

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