Java ile Nesne Yönelimli Programlama · Ders Notu

Bölüm 11: Veri Tabanı

Programın değişkenleri ve nesneleri bellekte yaşar, program kapanınca kaybolur. Öğrenci notları, banka hesapları, sensör ölçümleri ise yıllarca saklanmalı, hızlı aranabilmeli ve aynı anda birçok program onları güvenle değiştirebilmelidir. Bu işi veri tabanları yapar. Not önce veri tabanı kavramını, dosyadan farkını ve ilişkisel modeli anlatır. Ardından gömülü bir veri tabanı olan SQLite'ı ve ilişkisel veri tabanlarının ortak dili SQL'i işler. SQL kesimi tablo kurmayı, kayıt eklemeyi, sorgulamayı, güncellemeyi ve silmeyi; tabloları birleştirmeyi ve gruplamayı gösterir. Sonraki kesimler Java'nın veri tabanı arayüzü JDBC'yi ve kullanıcı girdisini güvenle kullanmanın yolunu anlatır. Son kesimler satırları nesnelere dönüştürmeyi ve birden çok komutu tek bir iş birimi olarak yürüten işlemleri işler. Bütün kodlar JDK 21 ve SQLite JDBC sürücüsüyle çalıştırılmıştır.

Öğrenme çıktıları

Kavramları açıklamak
Veri tabanının neden gerektiğini, düz dosyadan farkını ve ilişkisel modeli (tablo, satır, sütun, birincil anahtar) açıklamak.
SQL yazmak
CREATE TABLE, INSERT, SELECT, UPDATE ve DELETE komutlarını yazmak; sorguyu WHERE, ORDER BY ve LIMIT ile daraltmak.
İlişkileri kullanmak
Yabancı anahtarla tabloları bağlamak; JOIN ile birleştirmek, GROUP BY ve HAVING ile gruplamak; NULL değerleri doğru karşılaştırmak.
JDBC ile çalışmak
Connection, Statement ve ResultSet ile Java'dan SQLite'a bağlanıp kayıt eklemek, okumak, güncellemek ve silmek; kaynakları try-with-resources ile kapatmak.
Güvenlik ve bütünlük
SQL enjeksiyonunu PreparedStatement ile önlemek; commit ve rollback ile birden çok komutu tek bir işlem olarak yürütmek.
Nesnelere dönüştürmek
Sorgu sonucundaki satırları nesnelere çevirmek ve bir tablonun SQL'ini veri erişim nesnesinde (DAO) toplamak.

11.1Veri tabanı kavramı

Bu kesim veri tabanının ne olduğunu, hangi sorunu çözdüğünü ve veriyi nasıl düzenlediğini anlatır. Kod yoktur; burada tanımlanan kavramlar bütün JDBC kodunun temelidir.

11.1.1Veri tabanı neden gerekir?

Bir Java programında int toplam = 87; yazıldığında 87 değeri RAM'de, yani ana bellekte bir yere konur. Nesneler de yığın (heap) bölgesinde yaşar. Bu bellek hızlıdır ama kalıcı değildir. Program kapandığında işletim sistemi programın belleğini geri alır; değişkenler ve nesneler kaybolur. Elektrik kesilirse bellek içeriği de silinir.

Oysa birçok verinin programdan uzun yaşaması gerekir. Bir öğrencinin vize notu dönem boyunca, bir banka hesabının bakiyesi yıllarca, bir oyunun en yüksek skoru oyun her açıldığında gerekir. Verinin bu biçimde saklanmasına kalıcı depolama (persistent storage) denir. Kalıcı depolama disk gibi elektrik kesilince silinmeyen bir ortamda yapılır.

Tanım 11.1

Veri tabanı (database), düzenli biçimde saklanan ve kolayca sorgulanabilen veri topluluğudur. Veri tabanını oluşturan, koruyan, sorgulayan ve güncelleyen yazılıma veri tabanı yönetim sistemi (VTYS; database management system, DBMS) denir.

Günlük dilde ikisi çoğu zaman karıştırılır: “MySQL veri tabanı” denir ama MySQL aslında bir VTYS'dir. Veri tabanı ise o sistemin yönettiği tablolar ve içlerindeki kayıtlardır. Şekil 11.1 iki ortamın farkını gösterir. Program çalışırken veriyi bellekte işler, kalıcı olması gerekenleri veri tabanına yazar (INSERT, UPDATE). Program yeniden açıldığında veri diskten geri okunur (SELECT).

RAM (bellek)ogrencinotlar[]toplam = 87kapanınca silinirDiskokul.dbkalıcıkaydet: INSERT, UPDATEgeri oku: SELECTProgram yeniden açılınca veri diskten okunur.
Şekil 11.1: Bellek ve disk. RAM'deki değişkenler program kapanınca silinir; diskteki veri tabanı dosyası (okul.db) kalıcıdır.

Bir benzetme yapılabilir. Veri tabanı, kütüphanenin kataloglu arşivine benzer. Katalog olmasaydı bir kitabı bulmak için bütün rafları gezmek gerekirdi. Katalog sayesinde kitabın yeri birkaç adımda bulunur. Veri tabanı da milyonlarca kayıt arasından aranan kaydı bütün veriyi baştan sona okumadan bulur.

11.1.2Dosya mı, veri tabanı mı?

Kalıcı depolama için veri tabanı tek yol değildir. Program veriyi bir metin dosyasına ya da CSV (comma-separated values, virgülle ayrılmış değerler) dosyasına da yazabilir. Ayar dosyası, günlük kaydı ya da kısa bir liste için dosya yeterlidir ve daha basittir. Veri büyüyüp karmaşıklaştıkça ve aynı veriyi birden çok program kullanmaya başladıkça dosyanın sınırları ortaya çıkar. Tablo 11.1 iki yaklaşımı altı açıdan karşılaştırır.

Tablo 11.1: Düz dosya ile veri tabanının karşılaştırılması.
ÖzellikDüz dosya (txt, csv)Veri tabanı
AramaDosya baştan sona okunurİndeksle hızlı; tek satırlık SQL sorgusu
GüncellemeÇoğu zaman bütün dosya yeniden yazılırYalnız ilgili satır değişir
Eş zamanlı erişimİki program aynı anda yazarsa veri bozulabilirKilitleme ve işlemlerle güvenli
TutarlılıkBiçim denetimi tamamen programcıya kalırVeri tipleri ve kısıtlar (NOT NULL, UNIQUE, CHECK)
GüvenlikDosya izni: ya hepsi ya hiçbiriKullanıcı, tablo ve alan düzeyinde yetki
Hata sonrasıYarım kalan yazma dosyayı bozabilirYarım kalan işlem geri alınır

Tablodaki farkların her biri somut bir soruna karşılık gelir. Bir okulun öğrenci listesi CSV dosyasındaysa 20 000 satır arasından tek bir öğrenciyi bulmak için dosya satır satır okunur. Bir öğrencinin telefonunu değiştirmek için genellikle dosyanın tamamı okunur, bellekte değiştirilir ve yeniden yazılır. Çünkü metin dosyasının ortasındaki bir satır uzarsa arkasındaki bütün baytlar kayar.

En ciddi sorun eş zamanlı erişimdir. İki öğretmen aynı anda not girerse ikisi de dosyayı okur, kendi değişikliğini yapar ve dosyayı geri yazar. İkinci yazan, birincinin değişikliğini fark etmeden ezer. VTYS bu durumu kilitlerle ve işlemlerle yönetir. Yazma sırasında elektrik kesilirse dosya yarım kalabilir; veri tabanı ise yarım kalan işlemi açılışta geri alır.

Tutarlılık açısından da fark büyüktür. CSV dosyasının bir hücresine yaş yerine “yirmi” yazılmasını hiçbir şey engellemez; her okuyan program bu hatayı kendisi denetlemek zorundadır. Veri tabanında kurallar tablonun tanımına yazılır ve kurala uymayan kayıt baştan reddedilir. Şekil 11.2 temel yapı farkını özetler: dosyada program dosyaya doğrudan erişir, veri tabanında ise arada VTYS vardır.

programnotlar.csvarama, kilitleme, biçim: programcıdaprogramSQLVTYSdosyaindeks, kısıt, işlem, yetki: VTYS'de
Şekil 11.2: Dosyada arama, kilitleme ve biçim denetimi programa kalır; veri tabanında program SQL ile VTYS'ye başvurur, indeks, kilit, işlem, yetki ve tip denetimini VTYS yapar.

11.1.3Temel özellikler

Veri tabanlarını dosyalardan üstün kılan dört temel özellik vardır.

Verilere hızlı erişim. Veri tabanı, sık aranan sütunlar için indeks adı verilen ek yapılar tutar. İndeks sayesinde milyonlarca satır arasından aranan kayıt, bütün satırlara bakılmadan kısa sürede bulunur.

Alan düzeyinde güvenlik. Dosyada yetki genellikle bütün dosya içindir: dosyayı okuyabilen her şeyi okur. VTYS ise yetkiyi tablo, hatta sütun (alan) düzeyinde verebilir. Örneğin bir şirket veri tabanında maaş sütununu yalnız muhasebe birimi görebilir; öteki birimler aynı tablonun ad ve telefon sütunlarını okur.

Platform bağımsız, standart arayüzler. SQL bir standarttır; temel komutları bütün ilişkisel veri tabanlarında aynıdır. Java tarafında da JDBC adlı tek bir arayüz kümesi vardır. Aynı Java kodu, sürücü değiştirilerek farklı veri tabanlarına erişir.

Dosya yapısından bağımsızlık. Program, verinin diskte hangi dosyada, hangi sırayla, hangi biçimde tutulduğunu bilmez. Programın görevi ne istediğini SQL ile söylemektir; verinin nasıl bulunacağına VTYS karar verir (Şekil 11.3). Bu ayrım sayesinde VTYS bir indeks ekleyip verinin yerleşimini değiştirdiğinde program kodu değişmez.

programNE: SQLVTYS (SQLite, MySQL)NASILindeks / plandisk dosyaları
Şekil 11.3: Program SQL ile ne istediğini söyler; VTYS indeksleri ve disk dosyalarını kullanarak nasıl bulacağına karar verir.

Bu dört özelliğe iki önemli özellik daha eklenir. Birincisi veri bütünlüğü kısıtlarıdır: “bu sütun boş olamaz”, “bu değer tekrar edemez”, “yaş negatif olamaz” gibi kurallar tabloya yazılır ve VTYS bunlara uymayan kaydı reddeder. İkincisi işlem desteğidir: birden çok değişiklik ya birlikte uygulanır ya hiç uygulanmaz.

11.1.4İlişkisel model

Bugün en yaygın veri tabanı türü ilişkisel veri tabanıdır. Adı, verinin matematikteki “ilişki” (relation) kavramına dayanan tablolarla tutulmasından gelir.

Tanım 11.2

İlişkisel model (relational model), veriyi satır ve sütunlardan oluşan tablolarda tutar. Sütun verinin bir özelliğidir; adı ve veri tipi vardır. Satır (kayıt) bir varlığın bütün bilgisidir. Birincil anahtar (primary key), tablodaki her satırı tekil olarak tanımlayan sütundur; iki satırın birincil anahtarı aynı olamaz.

userstablo (table)idnameemail1Ahmet Yılmazahmet@example.com2Ayşe Demirayse@example.com3Can Kayacan@example.comsütun (alan, column)satır(kayıt, row)birincil anahtar (primary key): her satırda farklı
Şekil 11.4: users tablosu. Sütunlar id, name ve email'dir; her satır bir kullanıcıdır; id birincil anahtardır.

Şekil 11.4 bölüm boyunca kullanılan users tablosunu gösterir. Tablonun üç sütunu vardır. name sütununun tipi metindir (TEXT), id sütununun tipi tam sayıdır (INTEGER). İşaretli ikinci satır, Ayşe Demir adlı kullanıcının bütün bilgisidir: kimliği 2, e-postası ayse@example.com.

Birincil anahtar neden gerekir? İki kullanıcının adı aynı olabilir; iki “Ahmet Yılmaz” kaydı ayırt edilemezse hangisinin güncelleneceği belli olmaz. Ad ve soyad birlikte bile tekil değildir. Bu yüzden her satıra yalnız onu tanımlayan bir numara verilir. Bu numara değişmez ve başka bir satıra verilmez. Birincil anahtar başka tablolardan bu satırı göstermek için de kullanılır.

Java ile benzerlik kurmak yararlıdır. Tablo bir sınıfa, sütun bir alana, satır bir nesneye karşılık gelir. users tablosu, int id, String name ve String email alanlarını taşıyan bir User sınıfına benzer; her satır bu sınıfın bir nesnesidir. Benzerlik tam değildir: tablo yalnız veri tutar, metotları yoktur. Ama satırları nesnelere dönüştürürken bu eşleme doğrudan kullanılır.

Sık yapılan hata

Satırla sütunu karıştırmak. Sütun bir özelliktir ve bütün kayıtlarda bulunur (email). Satır bir kayıttır ve bütün özellikleri taşır (Ayşe Demir'in kimliği, adı ve e-postası). “Tabloya yeni bir öğrenci eklemek” yeni bir satır eklemektir; “öğrencilere doğum tarihi eklemek” ise yeni bir sütun eklemektir.

11.1.5Veri tabanı türleri

Veri tabanları veriyi düzenleme biçimine göre gruplanır. Üç grup bu ders için önemlidir.

İlişkisel veri tabanları veriyi tablolarda tutar ve SQL ile sorgulanır. Oracle, SQL Server, MySQL ve PostgreSQL bu gruptadır. Bu sistemler bir sunucu olarak çalışır; birçok istemci ağ üzerinden aynı veri tabanına bağlanır. Kullanımın büyük bölümü, yaklaşık yüzde 77'si, bu gruptadır.

NoSQL ve arama sistemleri tablo kullanmaz. Veriyi belge (JSON benzeri kayıtlar), anahtar–değer çiftleri ya da arama dizini olarak tutar. MongoDB bir belge veri tabanı, Elasticsearch bir arama motorudur. Belirli senaryolarda, örneğin yapısı sık değişen ya da çok büyük hacimli veride kullanılır. Kullanım payı yaklaşık yüzde 23'tür (Şekil 11.5).

İlişkisel%77NoSQL / arama%23yaklaşık kullanım payı
Şekil 11.5: Yaklaşık kullanım payı: ilişkisel veri tabanları yüzde 77, NoSQL ve arama sistemleri yüzde 23.

Gömülü ilişkisel veri tabanları ayrı bir sunucu olarak değil, programın içinde bir kütüphane olarak çalışır. En bilineni SQLite'tır. Veri paylaşımının az olduğu, tek bir programın kendi verisini sakladığı durumlarda yaygındır: telefon uygulamaları, tarayıcılar, gömülü cihazlar.

Bu bölümün örneklerinde kurulumu en kolay olan SQLite kullanılır. Bir sunucu kurmak, kullanıcı tanımlamak ya da ağ ayarı yapmak gerekmez. SQL komutları ve JDBC kodu öteki ilişkisel veri tabanlarında da neredeyse aynıdır. SQLite ile öğrenilen bilgi MySQL ya da PostgreSQL'e doğrudan taşınır; değişen yalnız bağlantı adresi ve sürücüdür.

11.2SQLite

11.2.1SQLite nedir?

SQLite, C diliyle yazılmış, açık kaynaklı ve ücretsiz bir ilişkisel veri tabanıdır. Onu öteki ilişkisel sistemlerden ayıran özellikleri şunlardır.

  • Gömülüdür (embedded): programın içinde bir kütüphane olarak çalışır. Java programı SQLite'ı çağırdığında ayrı bir süreç başlamaz; veri tabanı kodu programın kendi sürecinde yürür.
  • Sunucusuzdur (serverless): ayrı bir sunucu programı, kurulum ya da veri tabanı yöneticisi gerekmez.
  • Kendine yeter: bütün tablolar, indeksler ve şema tek bir disk dosyasında saklanır, örneğin sample.db. Veri tabanını yedeklemek ya da başka makineye taşımak bu dosyayı kopyalamaktır.
  • Çapraz platformdur: aynı .db dosyası Windows, Linux, macOS ve Android'de açılır.

SQLite dünyada en yaygın kullanılan gömülü ilişkisel veri tabanıdır. Her Android telefonda, her büyük tarayıcıda ve birçok masaüstü programında bulunur. Yaygın kullanım alanları mobil uygulamalar, gömülü sistemler, küçük ölçekli uygulamalar ve tarayıcı ya da e-posta istemcisi gibi masaüstü programlarıdır. Elektrik-elektronik mühendisliği açısından gömülü sistemler özellikle önemlidir: bir ölçüm cihazı, okuduğu değerleri bir SQLite dosyasında saklayıp daha sonra sorgulayabilir.

SQLite'ın uygun olmadığı yer de bellidir: ağ üzerinden çok sayıda kullanıcının aynı anda yazdığı büyük sistemler. SQLite aynı anda yalnız bir yazıcıya izin verir. Bir e-ticaret sitesinin binlerce eş zamanlı siparişi için MySQL ya da PostgreSQL gibi sunucu tabanlı bir sistem gerekir.

11.2.2Sunucu tabanlı ve gömülü veri tabanı

İki mimarinin farkı Şekil 11.6 ile görülür. Sunucu tabanlı bir veri tabanında iki süreç vardır: Java programı ve VT sunucusu. Program sunucuya ağ üzerinden bağlanır; MySQL'in varsayılan kapısı (port) 3306'dır. Bağlanırken kullanıcı adı ve şifre verilir. Veriyi diskteki dosyalara sunucu yazar; program bu dosyaları hiç görmez.

Sunucu tabanlı (MySQL, Oracle)Java programı1. süreçağport 3306VT sunucusu2. süreçveri dosyalarıkullanıcı adıve şifre gerekirGömülü (SQLite)tek süreçJava programıSQLite kütüphanesitest.dbtek dosyadosyayı doğrudan okur/yazar
Şekil 11.6: Sunucu tabanlı veri tabanında program ağ üzerinden ayrı bir sunucu sürecine bağlanır; SQLite ise programın içinde çalışır ve tek bir dosyaya doğrudan yazar.

Gömülü veri tabanında tek bir süreç vardır. SQLite kütüphanesi Java programının içinde yer alır ve .db dosyasını doğrudan okuyup yazar. Ağ, kullanıcı adı ve şifre yoktur. Tablo 11.2 farkları özetler.

Tablo 11.2: Sunucu tabanlı veri tabanı ile SQLite.
Sunucu tabanlıSQLite
KurulumSunucu kurulur, kullanıcı ve şifre tanımlanırProjeye tek bir jar eklenir
VeriSunucunun yönettiği birçok dosyaTek bir .db dosyası
Eş zamanlı yazmaÇok kullanıcıAynı anda tek yazıcı

11.2.3Sürücü, DB Browser ve çalıştırma

Java'nın SQLite ile konuşabilmesi için bir JDBC sürücüsü (driver) gerekir. SQLite için yaygın sürücü Xerial'in sqlite-jdbc kütüphanesidir (github.com/xerial/sqlite-jdbc). Sürücü tek bir jar dosyasıdır, örneğin sqlite-jdbc-3.40.0.0.jar. Bu jar hem Java sınıflarını hem de SQLite'ın her işletim sistemi için derlenmiş kütüphanesini içerir. Bu yüzden ayrıca SQLite kurmak gerekmez.

Veri tabanı dosyasının içini görmek için DB Browser for SQLite (sqlitebrowser.org) kullanılabilir. Bu program bir .db dosyasını açar, tabloları ve satırları görsel olarak gösterir ve SQL komutu çalıştırır. Java programının yaptığı değişiklikleri denetlemek için çok yararlıdır. Program bir satır ekledikten sonra DB Browser'da tablo yenilenir ve satırın gerçekten eklendiği görülür.

Eclipse'te sürücü projeye şöyle eklenir. Jar dosyası projede bir lib klasörüne konur. Projeye sağ tıklanıp Build Path → Configure Build Path… seçilir. Libraries sekmesinde Classpath seçilir, Add JARs… ile jar eklenir ve Apply and Close ile kapatılır. Komut satırından derleme ve çalıştırma ise şöyledir:

javac Baglan.java
java -cp ".;sqlite-jdbc-3.40.0.0.jar" Baglan

İlk satır programı derler. Derlemek için sürücü gerekmez, çünkü programın kullandığı java.sql paketi JDK ile gelir. İkinci satır programı çalıştırır. -cp seçeneği sınıf yolunu (classpath) verir: nokta, çalışma klasöründeki Baglan.class için; jar, sürücü için. Windows'ta yol öğeleri noktalı virgülle (;), Linux ve macOS'ta iki noktayla (:) ayrılır. Sürücü yalnız çalışma zamanında gerekir; jar sınıf yolunda yoksa program derlenir ama bağlanırken hata verir. Bu durumda alınan ileti “No suitable driver found …” biçimindedir.

11.3SQL temelleri

Java'dan veri tabanına bağlanmadan önce veri tabanının dilini tanımak gerekir. Java programı veri tabanına SQL komutlarını metin olarak gönderir; veri tabanı bu metni çözümler ve çalıştırır. Bu kesimdeki komutlar DB Browser for SQLite'ta da, bir Java programının içinde de aynen çalışır.

11.3.1Yaygın SQL komutları

Tanım 11.3

SQL (Structured Query Language, yapılandırılmış sorgu dili), ilişkisel veri tabanlarının ortak dilidir. Veri tabanından veri isteyen SQL komutuna sorgu (query) denir.

SQL, Java'dan farklı olarak bildirimsel (declarative) bir dildir. Java'da bir dizide arama yapmak için döngü yazılır: hangi öğeye hangi sırayla bakılacağı adım adım söylenir. SQL'de yalnız istenen sonuç tarif edilir: “puanı 70'ten büyük oyuncuları getir.” Sonucun nasıl üretileceğine VTYS karar verir. Tablo 11.3 en sık kullanılan beş komutu verir.

Tablo 11.3: Yaygın SQL komutları ve CRUD işlemleri.
KomutGöreviÖrnekCRUD
CREATE TABLEYeni tablo oluştururCREATE TABLE users (id INTEGER, name TEXT)—
INSERT INTOYeni kayıt eklerINSERT INTO users(name) VALUES('Ali')Create
SELECTTabloyu sorgularSELECT * FROM usersRead
UPDATEVar olan kayıtları güncellerUPDATE users SET name = 'Efe' WHERE id = 1Update
DELETEKayıtları silerDELETE FROM users WHERE id = 1Delete

Kalıcı veriyle yapılan dört temel işlem CRUD diye anılır. Ad, işlemlerin İngilizce baş harflerinden gelir: oluşturma (create), okuma (read), güncelleme (update) ve silme (delete). Hemen her veri tabanı uygulaması bu dört işlemin birleşimidir. Şekil 11.7 her harfin karşılık geldiği SQL komutunu gösterir. CREATE TABLE CRUD'un parçası değildir; veriyi değil, tablonun yapısını oluşturur.

C: INSERTR: SELECTU: UPDATED: DELETEtablo
Şekil 11.7: CRUD: tabloya ekleme (INSERT), okuma (SELECT), güncelleme (UPDATE) ve silme (DELETE).

Yazım kuralları kısadır. SQL anahtar kelimelerinde büyük-küçük harf fark etmez: select ile SELECT aynıdır. Okunabilirlik için anahtar kelimeler büyük, tablo ve sütun adları küçük yazılır. Metin değerler tek tırnak içine alınır: 'Ali'. Çift tırnak SQL'de değer için değil, ad için kullanılır; bu yüzden Java dizgisinin içindeki SQL'de metinler tek tırnakla yazılır ve iki dil birbirine karışmaz. SELECT * içindeki yıldız “bütün sütunlar” demektir. WHERE ise komutun hangi satırlara uygulanacağını seçer.

11.3.2CREATE TABLE, veri tipleri ve kısıtlar

Bir tablo, adı ve sütunlarıyla tanımlanır. Her sütunun bir adı, bir tipi ve isteğe bağlı kısıtları vardır. Aşağıdaki komut bir üye tablosu oluşturur:

CREATE TABLE IF NOT EXISTS uyeler (
  id    INTEGER PRIMARY KEY,   -- benzersiz
  name  TEXT NOT NULL,         -- boş olamaz
  email TEXT UNIQUE,           -- tekrar edemez
  yas   INTEGER CHECK (yas >= 0)
)

Komutun her parçasının bir görevi vardır. IF NOT EXISTS, tablo zaten varsa hata verilmemesini sağlar. Program her açılışta bu komutu çalıştırabilir; ilk açılışta tablo oluşur, sonrakilerde komut hiçbir şey yapmaz. -- ile başlayan kısımlar SQL'de satır sonuna kadar süren açıklamalardır, Java'daki // gibi.

INTEGER PRIMARY KEY SQLite'ta özel bir anlam taşır. Bu sütun satırın iç numarasıdır (rowid). Ekleme sırasında değer verilmezse SQLite tablodaki en büyük id'nin bir fazlasını atar; tablo boşsa 1 verir. Böylece programın numara üretmesi gerekmez.

Kısıtlar (constraint) tabloya yazılan kurallardır. NOT NULL sütunun boş bırakılamayacağını, UNIQUE değerin iki satırda tekrar edemeyeceğini, CHECK ise verilen koşulun her satırda doğru olması gerektiğini söyler. Kısıt bozulursa komut reddedilir ve tablo değişmez. Aşağıdaki deneme bu tabloya dokuz ekleme gönderir; her reddedilen ekleme için JDBC'nin verdiği iletiyi yazar:

kabul: INSERT INTO uyeler(name, email, yas) VALUES ('Ada', 'ada@x', 20)
[SQLITE_CONSTRAINT_NOTNULL] A NOT NULL constraint failed (NOT NULL constraint failed: uyeler.name)
[SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (CHECK constraint failed: yas >= 0)
[SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: uyeler.email)
[SQLITE_CONSTRAINT_PRIMARYKEY] A PRIMARY KEY constraint failed (UNIQUE constraint failed: uyeler.id)
kabul: INSERT INTO uyeler(name) VALUES ('Naz')
kabul: INSERT INTO uyeler(id, name) VALUES (10, 'Ece')
kabul: INSERT INTO uyeler(name) VALUES ('Nur')
kabul: INSERT INTO uyeler(name, yas) VALUES ('Ali', 'yirmi')
1 Ada ada@x 20 integer
2 Naz null null null
10 Ece null null null
11 Nur null null null
12 Ali null yirmi text

İletilerin her biri bir kısıta karşılık gelir. Adı verilmeyen satır NOT NULL constraint failed: uyeler.name, yaşı −3 olan satır CHECK constraint failed: yas >= 0 ile reddedilir. Ada'nın e-postasını ikinci kez kullanan satır UNIQUE kısıtına, id'si 1 olan ikinci satır birincil anahtara takılır. İletinin başındaki [SQLITE_CONSTRAINT_…] kısmı sürücünün eklediği hata kodudur; parantez içindeki asıl ileti SQLite'ın kendisinden gelir.

Çıktının son satırları INTEGER PRIMARY KEY'in davranışını gösterir. Naz 2 numarasını alır. Ece'ye elle 10 verilince bir sonraki satır (Nur) 11 alır: en büyük id + 1. Arada kalan 3–9 numaraları boş kalır; bu bir sorun değildir, çünkü birincil anahtarın ardışık olması gerekmez, yalnız tekil olması gerekir.

Son satır SQLite'a özgü bir inceliği ortaya koyar: INTEGER sütununa 'yirmi' metni yazılabilmiştir. SQLite esnek tiplidir; sütun tipi bir öneridir (type affinity). Değer sayıya çevrilebiliyorsa sayı olarak, çevrilemiyorsa metin olarak saklanır. CHECK (yas >= 0) de bu satırı durdurmamıştır, çünkü SQLite karşılaştırmasında her metin her sayıdan büyük sayılır. MySQL ve PostgreSQL böyle bir değeri reddeder. Bu yüzden Java tarafında değerin tipi doğru verilmeli ve sayılar setInt gibi tipli metotlarla gönderilmelidir. Tablo 11.4 SQLite tiplerinin Java karşılıklarını verir.

Tablo 11.4: SQLite veri tipleri, Java karşılıkları ve okuma metotları.
SQLite tipiJava karşılığıOkuma metodu
INTEGERint, longgetInt, getLong
REALdoublegetDouble
TEXTStringgetString
BLOBbyte[]getBytes
NULLdeğer yokgetString → null

BLOB (binary large object) ham bayt dizisidir; bir resim ya da bir ölçüm kaydı bu tiple saklanabilir. NULL ise bir tip değil, değerin olmadığını gösteren özel bir işarettir. Karşılaştırmalarda nasıl davrandığı ayrıca önemlidir.

11.3.3SQL komutları adım adım

Beş temel komutun tabloyu nasıl değiştirdiği, boş bir veri tabanında sırayla çalıştırılarak izlenebilir. İlk komut tabloyu kurar:

CREATE TABLE users (id INTEGER PRIMARY KEY,
                    name TEXT, email TEXT)

Bu komuttan sonra tablonun üç sütunu vardır ama hiç satırı yoktur. Ardından iki ekleme gelir:

INSERT INTO users(name, email)
  VALUES('Ahmet Yılmaz', 'ahmet@example.com')
INSERT INTO users(name, email)
  VALUES('Ayşe Demir', 'ayse@example.com')

Eklemelerde id verilmemiştir; yalnız name ve email sütunları adlarıyla anılır ve değerleri aynı sırayla VALUES içinde verilir. SQLite birinci satıra 1, ikinci satıra en büyük id + 1 = 2 atar. Her ekleme bir satırı etkiler. Sonra birinci satır güncellenir ve silinir; son olarak tablo okunur:

UPDATE users SET name = 'Mehmet Akdeniz'
  WHERE id = 1
DELETE FROM users WHERE id = 1
SELECT * FROM users

Komutlar bir Java programıyla tek tek çalıştırılıp her birinin ardından tablo yazdırıldığında Tablo 11.5 elde edilir. “Etkilenen satır” sütunu, JDBC'nin her komut için bildirdiği sayıdır.

Tablo 11.5: users tablosunun her komuttan sonraki durumu (JDK 21 ve SQLite JDBC ile çalıştırıldı).
KomutEtkilenen satırTablonun durumu
CREATE TABLE users …0(boş)
INSERT … 'Ahmet Yılmaz'1(1, Ahmet Yılmaz, ahmet@example.com)
INSERT … 'Ayşe Demir'1(1, Ahmet Yılmaz, ahmet@example.com)
(2, Ayşe Demir, ayse@example.com)
UPDATE … WHERE id = 11(1, Mehmet Akdeniz, ahmet@example.com)
(2, Ayşe Demir, ayse@example.com)
DELETE … WHERE id = 11(2, Ayşe Demir, ayse@example.com)
SELECT * FROM users—değişmez; sonuç: (2, Ayşe Demir, ayse@example.com)
usersidnameemail1Ahmet Yılmazahmet@example.com2Ayşe Demirayse@example.com usersidnameemail1Mehmet Akdenizahmet@example.com2Ayşe Demirayse@example.com usersidnameemail1Mehmet Akdenizahmet@example.com2Ayşe Demirayse@example.com
Şekil 11.8: İkinci eklemeden sonra (sol), güncellemeden sonra (orta; yalnız name değişti) ve silmeden sonra (sağ; 1 numaralı satırın üstü çizili).

Güncellemede yalnız SET ile anılan sütun değişir; email aynı kalır (Şekil 11.8). WHERE id = 1 yalnız birinci satırı seçer. Silme de aynı koşulla yalnız birinci satırı kaldırır. Son SELECT tabloyu değiştirmez, yalnız okur ve tek satır döndürür. Silinen 1 numarası Ayşe'ye geçmez: Ayşe'nin id'si 2 olarak kalır. Birincil anahtar bir kez verildikten sonra değişmez.

Sık yapılan hata

UPDATE ya da DELETE'te WHERE'i unutmak. UPDATE users SET name = 'X' hata vermez; tablodaki bütün satırların adını değiştirir. DELETE FROM users bütün satırları siler, tablonun kendisi kalır. Üç satırlı bir tabloda iki komut da 3 döndürür. Veri tabanı bunu bir hata olarak görmez; geri almanın tek yolu bir işlem içinde çalışmak ya da yedek almaktır.

11.3.4SELECT: süzme, sıralama, sınırlama

SELECT, SQL'in en çok kullanılan ve en zengin komutudur. Bu kesimdeki örnekler beş satırlık bir skor tablosuyla çalışır (Şekil 11.9).

skorlaroyuncupuanada72can95ece58efe95naz81
Şekil 11.9: skorlar tablosu.

Bir sorgu en fazla altı yan tümceden oluşur; bunların dördü burada kullanılır. SELECT hangi sütunların getirileceğini, FROM hangi tablodan okunacağını söyler. WHERE satırları bir koşula göre süzer. Koşulda =, <> (eşit değil), <, >= gibi karşılaştırmalar ve AND, OR bağlaçları kullanılır. LIKE 'e%' “e ile başlayan” anlamına gelir: % herhangi sayıda karakterin, _ tek karakterin yerini tutar. ORDER BY sonucu sıralar; DESC büyükten küçüğe, varsayılan ASC küçükten büyüğe demektir. LIMIT n ilk n satırı alır. Tablo 11.6 dört sorguyu ve sonuçlarını verir.

Tablo 11.6: skorlar tablosu üzerinde dört sorgu (sonuçlar çalıştırılarak alındı).
SorguSonuç
SELECT oyuncu, puan FROM skorlar WHERE puan < 90 ORDER BY puan DESCnaz 81, ada 72, ece 58
SELECT oyuncu, puan FROM skorlar ORDER BY puan DESC, oyuncu LIMIT 3can 95, efe 95, naz 81
SELECT oyuncu, puan FROM skorlar WHERE oyuncu LIKE 'e%'ece 58, efe 95
SELECT COUNT(*), AVG(puan), MAX(puan) FROM skorlar WHERE puan >= 604 · 85.75 · 95

Birinci sorgu 90'dan küçük puanları seçer; can ve efe (95) elenir. Kalan üç satır puana göre büyükten küçüğe dizilir. İkinci sorguda süzme yoktur. Sıralama iki ölçütlüdür: önce puan azalan, puanlar eşitse oyuncu adı artan sırada. can ile efe 95'te eşittir; alfabede can önce gelir. LIMIT 3 ilk üç satırı alır. İkinci ölçüt yazılmasaydı eşit puanlıların sırası belirsiz olurdu; sonuç VTYS'nin o anki yerleşimine bağlı kalırdı.

Üçüncü sorgu e ile başlayan iki adı döndürür. SQLite'ta LIKE ASCII harflerde büyük-küçük harf ayrımı yapmaz; LIKE 'E%' de aynı iki satırı verir. Dördüncü sorgu tek satır döndürür, çünkü COUNT, AVG ve MAX birer toplama fonksiyonudur (aggregate function): bir satır kümesinden tek bir değer üretir. 60 ve üstü dört puan vardır: 72, 95, 95 ve 81. Toplamları 343, ortalamaları 343 / 4 = 85,75'tir. En büyüğü 95'tir. SUM ve MIN da aynı ailedendir.

Bu fonksiyonların boş kümedeki davranışı akılda tutulmalıdır. Hiçbir satırın seçilmediği WHERE puan > 100 ile COUNT(*) 0, AVG(puan) ve SUM(puan) ise NULL döndürür. Sorgu yine de tek bir satır döndürür; satırın değerleri boştur.

11.3.5Kod izleme: bir SELECT sorgusu

SQL komutu yazılış sırasıyla çalışmaz. Aşağıdaki Java kodu bir sorguyu veri tabanına gönderir ve sonucu satır satır yazar. Burada önemli olan JDBC türleri değil, sorgunun hangi aşamalardan geçtiğidir.

String sql = "SELECT oyuncu, puan FROM skorlar "
           + "WHERE puan > 70 "
           + "ORDER BY oyuncu DESC LIMIT 2";
try (Statement st = conn.createStatement();
     ResultSet rs = st.executeQuery(sql)) {
  while (rs.next()) {
    System.out.println(rs.getString("oyuncu") + " "
                       + rs.getInt("puan"));
  }
}
naz 81
efe 95

Veri tabanı önce FROM'u işler: tablonun beş satırını alır. Sonra WHERE puan > 70 koşulunu her satıra uygular ve ece'yi (58) eler; dört satır kalır. Ardından ORDER BY oyuncu DESC satırları ada göre ters alfabetik sıraya dizer: naz, efe, can, ada. Sıralama puana göre değil, ada göredir. En son LIMIT 2 ilk iki satırı alır. Sonuç kümesi iki satırdır: (naz, 81) ve (efe, 95). Şekil 11.10 bu aşamaları gösterir.

FROMWHEREORDER BYLIMIToyuncupuanada72can95ece58efe95naz81ece 58 elendi FROMWHEREORDER BYLIMIToyuncupuannaz81efe95can95ada72ad, Z → A FROMWHEREORDER BYLIMIToyuncupuannaz81efe95can95ada72ilk 2 satır
Şekil 11.10: Sorgunun aşamaları. WHERE ece'yi eler (sol), ORDER BY adları ters sıraya dizer (orta), LIMIT 2 ilk iki satırı sonuç kümesi yapar (sağ).

Sonuç Java'ya bir ResultSet nesnesi olarak gelir. Bu nesnenin bir imleci (cursor) vardır ve imleç başlangıçta ilk satırın önünde durur. Her rs.next() çağrısı imleci bir satır ilerletir; satır varsa true, kalmadıysa false döndürür. Bu yüzden döngü while (rs.next()) biçimindedir: ilk çağrı imleci birinci satıra taşır, gövde o satırın sütunlarını okur. Tablo 11.7 on adımı verir.

Tablo 11.7: SELECT sorgusunun izlenmesi. Adım 2–5 veri tabanının içinde, adım 6–10 Java döngüsünde geçer.
AdımSatırDeğişkenlerYeni çıktı
11: String sql = "SELECT oyuncu, puan FROM skorlar "
2: + "WHERE puan > 70 "
3: + "ORDER BY oyuncu DESC LIMIT 2";
sql=SELECT … LIMIT 2—
25: ResultSet rs = st.executeQuery(sql)) {aşama=FROM—
35: ResultSet rs = st.executeQuery(sql)) {aşama=WHERE—
45: ResultSet rs = st.executeQuery(sql)) {aşama=ORDER BY—
55: ResultSet rs = st.executeQuery(sql)) {aşama=LIMIT—
66: while (rs.next()) {rs.next()=true—
77: System.out.println(rs.getString("oyuncu") + " "
8: + rs.getInt("puan"));
oyuncu=naz, puan=81naz 81
86: while (rs.next()) {rs.next()=true—
97: System.out.println(rs.getString("oyuncu") + " "
8: + rs.getInt("puan"));
oyuncu=efe, puan=95efe 95
106: while (rs.next()) {
10: }
rs.next()=false—

Mantıksal çalışma sırası bu örnekte görülür: FROM → WHERE → ORDER BY → LIMIT. SELECT'teki sütun seçimi sona doğru yapılır. Bu sıra gruplama sorgularında daha da önem kazanır. Bir sorgunun ne döndüreceğini kestirmek için onu yazılış sırasıyla değil, bu sırayla okumak gerekir.

Örnek 11.1

skorlar tablosunda SELECT oyuncu FROM skorlar WHERE puan > 90 OR oyuncu = 'ece' ve SELECT oyuncu FROM skorlar LIMIT 2 OFFSET 1 sorguları ne döndürür?

Birincisinde koşul her satır için ayrı değerlendirilir. can ve efe 95 puanla birinci kısmı, ece ikinci kısmı sağlar. ada (72) ve naz (81) iki kısmı da sağlamaz. Sonuç can, ece, efe'dir. Sıralama istenmediği için SQLite satırları eklenme sırasıyla verir. İkincisinde OFFSET 1 ilk satırı atlar, LIMIT 2 sonraki iki satırı alır: can, ece. Çalıştırılan sorgular bu sonuçları verdi. ORDER BY olmadan satır sırası standartça güvence altında değildir; sıra önemliyse her zaman ORDER BY yazılır.

11.4İlişkiler ve gruplama

Gerçek bir uygulamada veri tek bir tabloya sığmaz. Öğrenciler, bölümler, dersler, siparişler ayrı tablolarda durur ve birbirine bağlanır. Bu kesim tabloların neden bölündüğünü, nasıl bağlandığını ve bağlı tabloların tek sorguda nasıl birleştirildiğini anlatır.

11.4.1Normalleştirme

Bir mağazanın siparişleri tek bir tabloda tutulsun: her satırda müşterinin adı, telefonu ve sipariş edilen ürün. Ali üç kez sipariş verirse adı ve telefonu üç satırda tekrarlanır (Şekil 11.11, üstte). Bu tekrar yalnız yer israfı değildir; asıl sorun tutarsızlık riskidir.

tek tablo: siparis_kayitmusteritelefonurunAli555-11kalemAli555-11defterEce555-22silgiaynı telefoniki kez yazılmışiki tabloya bölünürmusterileridadtelefon1Ali555-112Ece555-22siparisleridmusteri_idurun11kalem21defter32silgimusteri_id → musteriler.id
Şekil 11.11: Tekrarlı tek tablo iki tabloya bölünür: musteriler ve siparisler. Siparişler müşteriye musteri_id ile bağlanır.

Tekrar üç tür bozulmaya, yani anomaliye, yol açar. Güncelleme anomalisi: Ali'nin telefonu değişince her satırı ayrı ayrı düzeltmek gerekir; biri unutulursa Ali'nin iki farklı telefonu olur ve hangisinin doğru olduğu bilinemez. Ekleme anomalisi: henüz siparişi olmayan bir müşteri kaydedilemez, çünkü her satır bir sipariştir. Silme anomalisi: Ece'nin tek siparişi silinince Ece'nin telefonu da kaybolur.

Tanım 11.4

Normalleştirme (normalization), tekrarı önlemek için veriyi her bilgi tek bir yerde duracak biçimde tablolara bölmektir. Bölünen tablolar bir anahtarla birbirine bağlanır.

Şeklin alt kısmında müşteri bilgisi musteriler tablosunda bir kez durur. siparisler tablosu müşterinin adını ve telefonunu değil, yalnız numarasını (musteri_id) tutar. Ali'nin telefonu değişince tek bir hücre güncellenir. Siparişi olmayan müşteri musteriler'e eklenebilir. Ece'nin siparişi silinse de Ece'nin kaydı kalır. Üç anomali birden ortadan kalkar.

Normalleştirmenin ilk kuralına birinci normal biçim denir: her hücrede tek bir değer bulunur. Bir müşterinin iki telefonu '555-11, 555-33' gibi tek hücrede bir liste olarak yazılmaz. Böyle bir hücrede “telefonu 555-33 olan müşteri” aramak zorlaşır, bir numarayı silmek metin işlemeye dönüşür. Telefonlar ya ayrı satırlara ya da ayrı bir telefonlar(musteri_id, numara) tablosuna taşınır. Daha ileri normal biçimler de vardır; bu ders için ilk kural ve “her bilgi tek yerde” ilkesi yeterlidir.

11.4.2Yabancı anahtar

Bölünen tabloları bağlayan sütunun özel bir adı vardır.

Tanım 11.5

Yabancı anahtar (foreign key), başka bir tablonun birincil anahtarını gösteren sütundur. Yabancı anahtarın değeri ya o tablodaki bir satırın anahtarına eşittir ya da NULL'dur.

Bölümün ilişki örnekleri iki tabloyla çalışır: bolumler ve ogrenciler. Her öğrencinin bir bölümü vardır ve bu bağ bolum_id sütunuyla kurulur:

CREATE TABLE bolumler (
  id INTEGER PRIMARY KEY,
  ad TEXT NOT NULL);
CREATE TABLE ogrenciler (
  id INTEGER PRIMARY KEY,
  ad TEXT NOT NULL,
  bolum_id INTEGER REFERENCES bolumler(id));

REFERENCES bolumler(id), bolum_id'nin bir yabancı anahtar olduğunu bildirir. Tablolara şu satırlar eklenir; Deniz'in bölümü henüz belli değildir:

INSERT INTO bolumler(ad) VALUES ('Bilgisayar'), ('Elektrik'), ('Makine');
INSERT INTO ogrenciler(ad, bolum_id)
  VALUES ('Ada', 1), ('Bora', 2), ('Cem', 1), ('Deniz', NULL);
bolumleridad1Bilgisayar2Elektrik3Makineogrencileridadbolum_id1Ada12Bora23Cem14DenizNULLbire çok: bir bölüm,çok öğrenciDeniz'in bölümü yok: bolum_id NULL
Şekil 11.12: ogrenciler.bolum_id sütunu bolumler.id sütununu gösterir. Bir bölümde çok öğrenci olabilir: bire çok ilişki. Deniz'in bölümü yoktur, bolum_id'si NULL'dur.

Şekil 11.12 ilişkiyi gösterir. Ada ve Cem'in bolum_id'si 1'dir; ikisi de Bilgisayar bölümündedir. Bir bölümde birden çok öğrenci olabilir ama bir öğrencinin tek bölümü vardır. Bu yapıya bire çok (one-to-many) ilişki denir. Java'da karşılığı, her Ogrenci nesnesinde bir Bolum referansı bulunmasıdır. Tabloda nesne referansı yerine anahtar değeri durur.

Yabancı anahtarın asıl yararı bütünlük denetimidir. Var olmayan 9 numaralı bölüme öğrenci eklemek ya da öğrencisi olan bir bölümü silmek, bağı koparır: öğrenci hiçbir yeri göstermeyen bir numara taşır. VTYS bu iki işlemi reddeder. Ancak SQLite geriye uyumluluk için bu denetimi varsayılan olarak yapmaz. Denetim her bağlantıda ayrıca açılır:

PRAGMA foreign_keys = ON;
INSERT INTO ogrenciler(ad, bolum_id) VALUES ('Efe', 9);   -- ret
DELETE FROM bolumler WHERE id = 2;                        -- ret: Bora bağlı
DELETE FROM bolumler WHERE id = 3;                        -- kabul: Makine boş

Aynı işlemler önce varsayılan ayarla, sonra denetim açıkken çalıştırıldığında şu çıktı elde edilir:

== PRAGMA yok (varsayılan)
foreign_keys = 0
kabul (1): INSERT INTO ogrenciler(ad, bolum_id) VALUES ('Efe', 9)
kabul (1): DELETE FROM bolumler WHERE id = 2
== PRAGMA foreign_keys = ON
ret: INSERT INTO ogrenciler(ad, bolum_id) VALUES ('Efe', 9) -> [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
ret: DELETE FROM bolumler WHERE id = 2 -> [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
kabul (1): DELETE FROM bolumler WHERE id = 3
kabul (1): INSERT INTO ogrenciler(ad, bolum_id) VALUES ('Nil', NULL)

Varsayılan ayarda PRAGMA foreign_keys 0'dır. 9 numaralı bölüme ekleme sessizce kabul edilir; Bora'nın bölümü de silinir ve Bora'nın bolum_id'si artık var olmayan bir satırı gösterir. Denetim açıkken iki işlem de FOREIGN KEY constraint failed ile reddedilir. Öğrencisi olmayan Makine bölümü ise silinebilir. NULL yabancı anahtar her zaman kabul edilir, çünkü “bölümü yok” anlamına gelir. Java'da bu ayar bağlantı açıldıktan hemen sonra stmt.execute("PRAGMA foreign_keys = ON") ile yapılır. Ayar bağlantıya özgüdür: her yeni bağlantıda yinelenmelidir.

Bire çok dışında bir ilişki türü daha sık görülür: çoka çok (many-to-many). Bir öğrenci birçok derse kayıtlıdır, bir derste birçok öğrenci vardır. Bu ilişki iki tablonun sütunlarıyla kurulamaz; üçüncü bir ara tablo gerekir:

CREATE TABLE kayitlar (
  ogrenci_id INTEGER REFERENCES ogrenciler(id),
  ders_id    INTEGER REFERENCES dersler(id),
  PRIMARY KEY (ogrenci_id, ders_id));

kayitlar tablosunun her satırı “şu öğrenci şu derse kayıtlı” bilgisidir. İki sütunun her biri ayrı bir tabloya yabancı anahtardır. Birincil anahtar iki sütunun birleşimidir; böylece aynı öğrenci aynı derse iki kez kaydedilemez.

11.4.3JOIN ile birleştirme

Normalleştirmenin bir bedeli vardır: “Ada hangi bölümde?” sorusunun cevabı artık iki tablodadır. ogrenciler'de yalnız 1 sayısı, bolumler'de 1'in “Bilgisayar” olduğu yazar. İki bilgiyi tek sonuçta buluşturmak için tablolar birleştirilir.

Tanım 11.6

Birleştirme (JOIN), iki tablonun satırlarını ON koşuluna göre eşleyip tek bir sonuç tablosu üreten SQL işlemidir. Yalnız JOIN (ya da INNER JOIN) eşi bulunan satırları, LEFT JOIN ise soldaki tablonun bütün satırlarını verir.

SELECT o.ad AS ogrenci, b.ad AS bolum
FROM ogrenciler o JOIN bolumler b
  ON o.bolum_id = b.id
ORDER BY o.id

Sorguda o ve b tablo takma adlarıdır: FROM ogrenciler o, sorgunun geri kalanında ogrenciler yerine o yazılabileceğini söyler. Takma adlar burada zorunludur, çünkü iki tabloda da ad adlı bir sütun vardır. o.ad öğrencinin, b.ad bölümün adıdır. AS sonuç sütununa yeni bir ad verir; Java tarafında sütun bu adla okunur: rs.getString("bolum"). ON o.bolum_id = b.id eşleme koşuludur: bir öğrenci satırı, id'si kendi bolum_id'sine eşit olan bölüm satırıyla eşlenir.

Aynı tablolarla LEFT JOIN yazıldığında sorgu şöyle olur:

SELECT o.ad AS ogrenci, b.ad AS bolum
FROM ogrenciler o LEFT JOIN bolumler b
  ON o.bolum_id = b.id
ORDER BY o.id

İki sorgunun sonuçları Tablo 11.8'da yan yanadır. Fark yalnız Deniz'dedir. Deniz'in bolum_id'si NULL olduğu için hiçbir bölümle eşleşmez. JOIN eşi olmayan satırı sonuca almaz. LEFT JOIN ise soldaki tablonun (ogrenciler) her satırını korur; eşi yoksa sağ tablonun sütunlarını NULL ile doldurur. Java'da bu değer getString ile okununca null gelir.

Tablo 11.8: JOIN ve LEFT JOIN sonuçları.
ogrenciJOIN (INNER)LEFT JOIN
AdaBilgisayarBilgisayar
BoraElektrikElektrik
CemBilgisayarBilgisayar
Denizsatır yokNULL

Hangisinin seçileceği soruya bağlıdır. “Bölümü olan öğrencileri bölüm adıyla listele” için JOIN, “bütün öğrencileri listele, bölümü varsa adını da yaz” için LEFT JOIN kullanılır. Tabloların sırası LEFT JOIN'de önemlidir: bolumler b LEFT JOIN ogrenciler o yazılırsa korunan taraf bölümler olur. Bu sorgu Makine bölümünü öğrencisi NULL olarak getirir.

İki hata sık yapılır. Birincisi takma ad yazmadan SELECT ad yazmaktır. SQLite hangi ad'ın kastedildiğini bilemez ve sorguyu ambiguous column name: ad iletisiyle reddeder. İkincisi ON koşulunu unutmaktır:

SELECT COUNT(*) FROM ogrenciler, bolumler      -- 12

Koşulsuz birleştirmede her satır her satırla eşleşir. Dört öğrenci ve üç bölümden 4 × 3 = 12 satır çıkar. Buna Kartezyen çarpım denir. Sonuç anlamsızdır: Ada hem Bilgisayar, hem Elektrik, hem Makine bölümünde görünür. Büyük tablolarda bu hata çok pahalıdır. 10 000 öğrenci ve 100 bölüm bir milyon satır üretir.

11.4.4Kod izleme: JOIN sorgusu

Aşağıdaki kod birleştirme sorgusunu Java'dan çalıştırır. stmt, açık bir bağlantı üzerinde oluşturulmuş bir Statement'tır.

String sql = "SELECT o.ad AS ogrenci, b.ad AS bolum "
    + "FROM ogrenciler o JOIN bolumler b "
    + "ON o.bolum_id = b.id ORDER BY o.id";
try (ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) {
    System.out.println(rs.getString("ogrenci") + " - "
        + rs.getString("bolum"));
  }
}
Ada - Bilgisayar
Bora - Elektrik
Cem - Bilgisayar

Veri tabanı ogrenciler'in her satırı için bolumler'de id'si eşleşen satırı arar. Ada'nın bolum_id'si 1'dir; 1 numaralı bölüm Bilgisayar'dır. Bora 2'ye, yani Elektrik'e bağlanır. Cem yine 1'e bağlanır; aynı bölüm satırı ikinci kez kullanılır. Bölüm tablosunda Bilgisayar bir kez yazılıdır ama sonuçta iki kez görünür. Deniz'in bolum_id'si NULL'dur ve NULL hiçbir id'ye eşit sayılmaz; Deniz sonuca girmez (Şekil 11.13).

ogrenciler oadbolum_idAda1Bora2Cem1DenizNULLbolumler bidad1Bilgisayar2Elektrik3MakinesonuçogrencibolumAdaBilgisayarBoraElektrikCemBilgisayarNULL = ? → eşi yok ogrenciler oadbolum_idAda1Bora2Cem1DenizNULLbolumler bidad1Bilgisayar2Elektrik3MakinesonuçogrencibolumAdaBilgisayarBoraElektrikCemBilgisayarnext() → false
Şekil 11.13: Deniz'in NULL bölümü hiçbir satırla eşleşmez (sol); sonuç kümesi üç satırdır (sağ).
Tablo 11.9: JOIN sorgusunun izlenmesi.
AdımSatırDeğişkenlerYeni çıktı
11: String sql = "SELECT o.ad AS ogrenci, b.ad AS bolum "
2: + "FROM ogrenciler o JOIN bolumler b "
3: + "ON o.bolum_id = b.id ORDER BY o.id";
——
24: try (ResultSet rs = stmt.executeQuery(sql)) {——
34: try (ResultSet rs = stmt.executeQuery(sql)) {——
44: try (ResultSet rs = stmt.executeQuery(sql)) {——
54: try (ResultSet rs = stmt.executeQuery(sql)) {——
65: while (rs.next()) {
6: System.out.println(rs.getString("ogrenci") + " - "
7: + rs.getString("bolum"));
satır=1Ada - Bilgisayar
75: while (rs.next()) {
6: System.out.println(rs.getString("ogrenci") + " - "
7: + rs.getString("bolum"));
satır=2Bora - Elektrik
85: while (rs.next()) {
6: System.out.println(rs.getString("ogrenci") + " - "
7: + rs.getString("bolum"));
satır=3Cem - Bilgisayar
95: while (rs.next()) {
9: }
satır=yok—

Java tarafı önceki örnekle aynıdır: imleç sonuç kümesinde satır satır ilerler. Sütunlar AS ile verilen adlarla okunur: "ogrenci" ve "bolum". rs.getString("ad") yazılsaydı sonuçta ad adlı sütun olmadığı için sürücü hata verirdi. LEFT JOIN yazılsaydı dördüncü satır olarak Deniz - null yazılırdı; bu da çalıştırılarak doğrulandı.

11.4.5GROUP BY ve HAVING

Toplama fonksiyonları bütün tabloya uygulandığında tek bir değer verir. Çoğu zaman ise her grup için ayrı bir değer istenir: her müşterinin sipariş sayısı, her bölümün öğrenci sayısı. Bu kesimin örnekleri altı satırlık bir sipariş tablosuyla çalışır:

INSERT INTO siparisler(musteri, tutar) VALUES
  ('Ali', 120), ('Ece', 80), ('Ali', 50),
  ('Can', 200), ('Ali', 30), ('Ece', 40);

GROUP BY musteri, musteri değeri aynı olan satırları bir grupta toplar. Toplama fonksiyonları her grup için ayrı hesaplanır ve sonuçta her grup tek bir satıra dönüşür (Şekil 11.14). Ali'nin üç siparişi vardır: 120 + 50 + 30 = 200. Can'ın tek siparişi 200, Ece'nin iki siparişi 80 + 40 = 120'dir.

siparisleridmusteritutar1Ali1202Ece803Ali504Can2005Ece406Ali30GROUP BY musterimusteriadettoplamAli3200Can1200Ece2120grup başına bir satır
Şekil 11.14: siparisler tablosu müşteriye göre üç gruba ayrılır; her grup için adet ve toplam hesaplanır.

Süzme gruplamadan önce ya da sonra yapılabilir. WHERE gruplamadan önce satırları süzer. HAVING ise gruplamadan sonra grupları süzer. Bu yüzden toplama fonksiyonu içeren bir koşul WHERE'e yazılamaz: WHERE çalışırken henüz grup yoktur, dolayısıyla SUM(tutar) da yoktur. WHERE SUM(tutar) >= 150 yazılırsa SQLite misuse of aggregate: SUM() iletisiyle reddeder. Tablo 11.10 üç durumu karşılaştırır.

Tablo 11.10: SELECT musteri, COUNT(*), SUM(tutar) FROM siparisler … sorgusunun üç biçimi.
Sorgunun devamıSonuç
GROUP BY musteriAli 3 200 · Can 1 200 · Ece 2 120
WHERE tutar >= 50 GROUP BY musteriAli 2 170 · Can 1 200 · Ece 1 80
GROUP BY musteri HAVING SUM(tutar) >= 150Ali 3 200 · Can 1 200

İkinci satırda WHERE tutar >= 50 önce tek tek siparişleri süzer: Ali'nin 30'luk ve Ece'nin 40'lık siparişi elenir. Gruplar kalan siparişlerden kurulur. Ali'nin adedi 2'ye, toplamı 170'e düşer. Üçüncü satırda bütün siparişler gruplanır, sonra toplamı 150'nin altında kalan Ece'nin grubu elenir. İki sorgu benzer görünür ama farklı soruları cevaplar: biri “50 TL ve üstü siparişleri müşteriye göre say”, öteki “toplamı 150'yi aşan müşterileri bul” der.

Bir sorgunun yan tümceleri mantıksal olarak şu sırayla çalışır:

FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

Bu sıra birçok kuralı açıklar. WHERE toplama fonksiyonu kullanamaz, çünkü gruplamadan önce gelir. ORDER BY ise SELECT'te verilen takma adları kullanabilir, çünkü ondan sonra gelir.

Gruplama birleştirmeyle de kullanılır. Her bölümün öğrenci sayısı şöyle bulunur:

SELECT b.ad, COUNT(o.id)
FROM bolumler b LEFT JOIN ogrenciler o
  ON o.bolum_id = b.id
GROUP BY b.id

Sonuç Bilgisayar 2 · Elektrik 1 · Makine 0'dır. Burada iki ayrıntı önemlidir. LEFT JOIN sayesinde öğrencisi olmayan Makine de sonuca girer; düz JOIN yazılsaydı hiç görünmezdi. İkinci ayrıntı COUNT(o.id)'dir. COUNT(sütun) yalnız NULL olmayan değerleri sayar. Makine'nin tek birleşik satırında o.id NULL'dur ve sayılmaz; sonuç 0 olur. COUNT(*) ise satırları sayar. Yazılsaydı Makine'ye 1 verirdi; bu da çalıştırılarak doğrulandı.

Gruplamada bir tuzak daha vardır. SELECT musteri, tutar FROM siparisler GROUP BY musteri sorgusunda tutar ne gruplanmış ne de bir toplama fonksiyonunun içindedir. Ali'nin grubunda üç farklı tutar vardır; hangisi yazılacak? SQLite hata vermez ve gruptaki satırlardan birinin değerini getirir. Denemede Ali 120, Can 200, Ece 80 çıktı. PostgreSQL ve SQL Server gibi birçok veri tabanı bu sorguyu reddeder. Kural basittir: SELECT'teki her sütun ya GROUP BY'da bulunur ya da bir toplama fonksiyonunun içindedir.

11.4.6Kod izleme: GROUP BY sorgusu

Aşağıdaki kod gruplama, grup süzme ve iki ölçütlü sıralamayı tek sorguda birleştirir:

String sql = "SELECT musteri, COUNT(*) AS adet, "
    + "SUM(tutar) AS toplam FROM siparisler "
    + "GROUP BY musteri HAVING SUM(tutar) >= 150 "
    + "ORDER BY toplam DESC, musteri";
try (ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) {
    System.out.println(rs.getString("musteri") + ": "
        + rs.getInt("adet") + " sipariş, "
        + rs.getInt("toplam") + " TL");
  }
}
Ali: 3 sipariş, 200 TL
Can: 1 sipariş, 200 TL

Veri tabanı önce altı satırı müşteriye göre üç gruba ayırır. Her grup için COUNT(*) ve SUM(tutar) hesaplanır: Ali 3 ve 200, Can 1 ve 200, Ece 2 ve 120. HAVING SUM(tutar) >= 150 Ece'nin grubunu eler. ORDER BY toplam DESC, musteri kalan iki grubu toplama göre azalan sıraya dizer. Ali ile Can 200'de eşittir; ikinci ölçüt ad olduğu için Ali önce gelir (Şekil 11.15).

siparisleridmusteritutar1Ali1202Ece803Ali504Can2005Ece406Ali30gruplarmusteriadettoplamAli3200Can1200Ece2120 siparisleridmusteritutar1Ali1202Ece803Ali504Can2005Ece406Ali30gruplarmusteriadettoplamAli3200Can1200Ece2120HAVING: 120 < 150 siparisleridmusteritutar1Ali1202Ece803Ali504Can2005Ece406Ali30gruplarmusteriadettoplamAli3200Can1200Ece2120HAVING: 120 < 150sonuç (sıralı)musteriadettoplamAli3200Can1200eşitlik → ad: Ali, Can
Şekil 11.15: Gruplar ve toplamları (sol), HAVING Ece'yi eler (orta), sıralanmış sonuç (sağ).
Tablo 11.11: GROUP BY sorgusunun izlenmesi.
AdımSatırDeğişkenlerYeni çıktı
11: String sql = "SELECT musteri, COUNT(*) AS adet, "
2: + "SUM(tutar) AS toplam FROM siparisler "
3: + "GROUP BY musteri HAVING SUM(tutar) >= 150 "
4: + "ORDER BY toplam DESC, musteri";
——
25: try (ResultSet rs = stmt.executeQuery(sql)) {——
35: try (ResultSet rs = stmt.executeQuery(sql)) {——
45: try (ResultSet rs = stmt.executeQuery(sql)) {——
55: try (ResultSet rs = stmt.executeQuery(sql)) {——
66: while (rs.next()) {
7: System.out.println(rs.getString("musteri") + ": "
8: + rs.getInt("adet") + " sipariş, "
9: + rs.getInt("toplam") + " TL");
satır=1Ali: 3 sipariş, 200 TL
76: while (rs.next()) {
7: System.out.println(rs.getString("musteri") + ": "
8: + rs.getInt("adet") + " sipariş, "
9: + rs.getInt("toplam") + " TL");
satır=2Can: 1 sipariş, 200 TL
86: while (rs.next()) {
11: }
satır=yok—

Java tarafında takma adlar (adet, toplam) sütun adı gibi okunur: rs.getInt("adet"). Sonuçta grup başına bir satır vardır; tek tek siparişler görünmez. Ali'nin üç siparişi tek bir “Ali: 3 sipariş, 200 TL” satırına dönüşmüştür.

11.4.7NULL: değerin olmaması

Deniz'in bölümü bilinmiyor. Bu durum tabloda NULL ile gösterilir.

Tanım 11.7

SQL'de NULL, değerin bilinmediğini ya da olmadığını gösteren özel işarettir. NULL ne 0'dır ne de boş metindir (''). NULL ile yapılan her karşılaştırma “bilinmiyor” sonucunu verir.

SQL üç değerli bir mantık kullanır: doğru, yanlış ve bilinmiyor. bolum_id = NULL karşılaştırması Deniz için bile doğru değil, bilinmiyordur. Bilinmeyen bir değerin başka bir bilinmeyene eşit olup olmadığı da bilinemez. WHERE yalnız koşulu doğru olan satırları seçer; bu yüzden aşağıdaki ilk sorgu hiçbir satır döndürmez:

SELECT ad FROM ogrenciler WHERE bolum_id = NULL;      -- 0 satır
SELECT ad FROM ogrenciler WHERE bolum_id IS NULL;     -- Deniz
SELECT ad FROM ogrenciler WHERE bolum_id <> 1;        -- yalnız Bora
SELECT ad, COALESCE(bolum_id, 0) FROM ogrenciler;     -- Deniz 0
SELECT COUNT(*), COUNT(bolum_id) FROM ogrenciler;     -- 4 ve 3

Doğru yazım IS NULL ve IS NOT NULL'dur. Üçüncü satırdaki sonuç daha şaşırtıcıdır. bolum_id <> 1 Deniz'i de seçmez, çünkü NULL'un 1'den farklı olduğu bile bilinmez. “Bilgisayar dışındaki öğrenciler” isteniyorsa WHERE bolum_id <> 1 OR bolum_id IS NULL yazılır.

NULL aritmetik işlemlere de yayılır: NULL + 1'in sonucu NULL'dur. COALESCE(bolum_id, 0), ilk argüman NULL ise ikincisini döndürür; böylece NULL yerine 0 konur. Toplama fonksiyonları NULL'ları atlar. COUNT(*) satırları sayar ve 4 verir; COUNT(bolum_id) NULL olmayan değerleri sayar ve 3 verir. Hiç satır eşleşmezse AVG ve SUM NULL döndürür, COUNT 0 döndürür. Bu satırların hepsi bir Java programında çalıştırılarak doğrulandı.

Java tarafında da bir sorun vardır. int gibi temel tipler null olamaz. rs.getInt NULL bir sütun için 0 döndürür. Peki 0, gerçekten 0 mı yoksa NULL mı? Bunu ayırt etmek için okumanın hemen ardından wasNull() çağrılır:

String sql = "SELECT ad, bolum_id FROM ogrenciler "
    + "WHERE id >= 3";
try (ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) {
    int b = rs.getInt("bolum_id");
    boolean bos = rs.wasNull();
    System.out.println(rs.getString("ad") + " " + b
        + (bos ? " (NULL)" : ""));
  }
}
Cem 1
Deniz 0 (NULL)
== geç wasNull
Cem 1
Deniz 0
getString: null, getObject: null
SELECT ad FROM ogrenciler WHERE bolum_id = NULL
  -> 0 satır: 
SELECT ad FROM ogrenciler WHERE bolum_id IS NULL
  -> 1 satır: Deniz; 
SELECT ad FROM ogrenciler WHERE bolum_id IS NOT NULL
  -> 3 satır: Ada; Bora; Cem; 
SELECT ad FROM ogrenciler WHERE bolum_id <> 1
  -> 1 satır: Bora; 
SELECT NULL + 1, NULL = NULL, NULL IS NULL, COALESCE(NULL, 0)
  -> 1 satır: null null 1 0; 
SELECT ad, COALESCE(bolum_id, 0) FROM ogrenciler
  -> 4 satır: Ada 1; Bora 2; Cem 1; Deniz 0; 
SELECT COUNT(*), COUNT(bolum_id) FROM ogrenciler
  -> 1 satır: 4 3; 
SELECT AVG(bolum_id), SUM(bolum_id) FROM ogrenciler WHERE id > 10
  -> 1 satır: null null; 
SELECT AVG(bolum_id), SUM(bolum_id) FROM ogrenciler
  -> 1 satır: 1.33333333333333 4; 
SELECT ad FROM ogrenciler WHERE NOT (bolum_id = 1)
  -> 1 satır: Bora; 

Çıktının ilk iki satırı sunumdaki programındır. Cem'in bölümü 1'dir ve wasNull() false döner. Deniz için getInt 0 verir, wasNull() true döner ve satıra “(NULL)” eklenir. wasNull() yalnız son okunan sütuna bakar. “geç wasNull” başlıklı ikinci denemede bolum_id'den sonra getString("ad") okunmuş, wasNull() ondan sonra çağrılmıştır. Bu kez ad sorulmuş olur; ad NULL olmadığı için Deniz'in satırına “(NULL)” yazılmaz. getString ve getObject ise NULL için doğrudan Java'nın null'unu döndürür; bu metotlarda wasNull() gerekmez. Çıktının kalan satırları yukarıdaki SQL sorgularının gerçek sonuçlarıdır.

Sık yapılan hata

WHERE bolum_id = NULL yazmak. Sorgu hata vermez, yalnız hiçbir satır döndürmez; program “bölümsüz öğrenci yok” sonucuna varır. NULL denetimi her zaman IS NULL ile yapılır. Java'daki karşılığı da benzerdir: getInt'in 0 döndürmesi değerin 0 olduğunu göstermez; wasNull() ilgili getInt'in hemen ardından çağrılır.

11.4.8İndeks

Veri tabanının hızlı aramasının sırrı indekstir.

Tanım 11.8

İndeks (database index), bir sütunun değerlerini sıralı tutan ve her değerin bulunduğu satırı gösteren ek yapıdır. SQLite indeksi bir B-ağacı (B-tree) olarak saklar.

İndeks, kitabın sonundaki dizine benzer. “Kalıtım” kelimesinin geçtiği sayfalar, kitap baştan sona okunmadan dizinden bulunur. İndeks olmadan WHERE ad = 'Cem' sorgusu tablonun her satırına bakar; buna tarama (scan) denir. İndeks varken sıralı yapıda ikili aramaya benzer bir arama yapılır ve doğrudan Cem'in satırına gidilir (Şekil 11.16). n satırlı bir tabloda tarama n karşılaştırma, B-ağacında arama ise yaklaşık log n adım ister. Bir milyon satırda bu fark bir milyon ile yirmi civarı arasındadır.

indeks yok: SCAN (her satıra bakılır)DenizAdaCemBora4 karşılaştırmaidx_ogr_ad: sıralı + satıra işaretAda→ satır 1Bora→ satır 2Cem→ satır 3Deniz→ satır 4doğrudan Cem'e iner
Şekil 11.16: İndekssiz aramada bütün satırlar taranır (4 karşılaştırma); indeks sıralı olduğu için aranan ada doğrudan gidilir.

İndeks CREATE INDEX ile oluşturulur. SQLite'ın EXPLAIN QUERY PLAN komutu, bir sorgunun nasıl yürütüleceğini, yani tablonun taranıp taranmayacağını ya da indeksin kullanılıp kullanılmayacağını gösterir:

CREATE INDEX idx_ogr_ad ON ogrenciler(ad);
EXPLAIN QUERY PLAN
  SELECT * FROM ogrenciler WHERE ad = 'Cem';

Tablo 11.12 dört durumun planlarını verir. Planlar SQLite 3.40.0 ile alındı.

Tablo 11.12: EXPLAIN QUERY PLAN çıktıları.
DurumEXPLAIN QUERY PLAN çıktısı
İndeks yokken, WHERE ad = 'Cem'SCAN ogrenciler
İndeks varken, WHERE ad = 'Cem'SEARCH ogrenciler USING INDEX idx_ogr_ad (ad=?)
WHERE id = 3SEARCH ogrenciler USING INTEGER PRIMARY KEY (rowid=?)
WHERE ad LIKE '%em'SCAN ogrenciler

SCAN tablonun taranacağını, SEARCH bir indeksle arama yapılacağını gösterir. Birincil anahtar için ayrıca indeks oluşturmak gerekmez; INTEGER PRIMARY KEY zaten tablonun kendi sıralama anahtarıdır. UNIQUE sütunların indeksi de kendiliğinden oluşur, çünkü VTYS tekrarı denetlemek için bu indekse ihtiyaç duyar.

Son satır indeksin sınırını gösterir. LIKE '%em' “em ile biten” demektir. İndeks adları baştan sıralı tutar; sonu “em” olan adlar dizinin her yerine dağılmıştır. Bu yüzden baştaki joker indeksi kullanamaz. Denemede LIKE 'C%' de SCAN üretti. SQLite'ta LIKE büyük-küçük harf ayırmaz, sıradan indeks ise ayırır. Bu yüzden önek araması da varsayılan ayarlarla indeksi kullanmadı.

İndeksin bir bedeli vardır. Her INSERT, UPDATE ve DELETE yalnız tabloyu değil, indeksi de günceller; indeks diskte de yer kaplar. Bu yüzden her sütuna indeks konmaz. İndeks, sık aranan ve WHERE, JOIN ya da ORDER BY'da kullanılan sütunlara konur. Etkinin büyüklüğünü görmek için 200 000 satırlık bir tabloda ad sütununa göre 200 arama yapıldı. İndekssiz aramalar 633 ms, indeks oluşturulduktan sonra aynı aramalar 1 ms sürdü. Süreler makineye ve çalıştırmaya göre değişir; oran ise her seferinde yüzlerce kattır.

11.5JDBC

SQL komutları veri tabanının dilidir; Java programının bu komutları veri tabanına iletmesi, sonuçları da geri alması gerekir. Bu köprüyü JDBC kurar.

11.5.1JDBC nedir?

Tanım 11.9

JDBC (Java Database Connectivity), Java'nın veri tabanlarına bağlanmak için sunduğu standart API'dir (application programming interface, uygulama programlama arayüzü). java.sql paketinde bulunur ve JDK ile gelir. Belirli bir veri tabanıyla konuşan kodu ise o veri tabanının JDBC sürücüsü sağlar.

JDBC'nin yapısı, nesne yönelimli programlamanın arayüz kavramının güzel bir uygulamasıdır. java.sql paketindeki Connection, Statement ve ResultSet birer arayüzdür; yalnız hangi metotların bulunacağını söyler. Bu arayüzleri her veri tabanı üreticisinin sürücüsü kendi sınıflarıyla uygular. SQLite sürücüsü SQLite dosyasına, MySQL sürücüsü MySQL sunucusuna nasıl konuşulacağını bilir (Şekil 11.17).

Java programınızJDBC API (java.sql)Connection · Statement · ResultSetDriverManagerSQLite sürücüsüsqlite-jdbc.jarMySQL sürücüsümysql-connector-j.jarsample.dbMySQL sunucusu
Şekil 11.17: JDBC katmanları. Program yalnız JDBC arayüzlerini kullanır; DriverManager uygun sürücüyü seçer; sürücü SQLite dosyasına ya da MySQL sunucusuna bağlanır.

Program yalnız arayüzleri bildiği için veri tabanını değiştirmek çoğu zaman yalnız sürücü jar'ını ve bağlantı adresini değiştirmektir. Burada çok biçimlilik iş başındadır. Programdaki değişkenin tipi Connection'dır. Çalışma zamanında bu değişkenin gösterdiği gerçek nesnenin sınıfı SQLite sürücüsündeki org.sqlite.jdbc4.JDBC4Connection'dır. Bu ad conn.getClass().getName() ile yazdırılarak doğrulandı. Program conn.createStatement() çağırdığında JVM, Connection arayüzünün bu sınıftaki gerçeklemesini çalıştırır. Aynı satır MySQL'de MySQL sürücüsünün gerçeklemesini çalıştırır.

Arayüzün nesnesi doğrudan oluşturulamaz. new Connection() yazılırsa derleyici reddeder:

Ana.java:4: error: Connection is abstract; cannot be instantiated
    Connection conn = new Connection();
                      ^
1 error

Bağlantı nesnesini sürücünün sınıfından new ile kurmak da mümkündür ama yanlış olur, çünkü program bir sürücüye bağımlı hâle gelir. Bağlantı her zaman DriverManager.getConnection ile alınır; hangi sınıfın nesnesinin döneceğine sürücü karar verir.

11.5.2JDBC'nin temel türleri

Bir JDBC programı birkaç türle yazılır. Tablo 11.13 bunları ve önemli metotlarını verir.

Tablo 11.13: JDBC'nin temel türleri. Hepsi java.sql paketindedir.
TürGöreviÖnemli metotlar
DriverManagerUygun sürücüyü bulur, bağlantı açargetConnection(url), getConnection(url, kullanici, sifre)
ConnectionVeri tabanıyla açık bir oturumcreateStatement(), prepareStatement(sql), commit(), rollback(), close()
StatementSabit bir SQL metnini çalıştırırexecute, executeUpdate, executeQuery
PreparedStatement? yer tutuculu, önceden hazırlanan SQLsetInt, setString, executeUpdate(), executeQuery()
ResultSetSorgu sonucundaki satırlar ve bir imleçnext(), getInt("id"), getString("name")
SQLExceptionVeri tabanı hatası; kontrol edilen istisnagetMessage(), getErrorCode()

Türler bir zincir oluşturur. DriverManager bir Connection verir. Connection bir Statement ya da PreparedStatement üretir. Bu nesne bir sorgu çalıştırınca ResultSet döner. Her halka bir öncekinden elde edilir; programcı yalnız DriverManager'ı doğrudan çağırır.

Bu türlerin kullanılabilmesi için dosyanın başına import java.sql.*; yazılır. Unutulursa derleyici türleri bulamaz:

Ana.java:3: error: cannot find symbol
    Connection conn = DriverManager.getConnection("jdbc:sqlite:sample.db");
    ^
  symbol:   class Connection
  location: class Ana
Ana.java:3: error: cannot find symbol
    Connection conn = DriverManager.getConnection("jdbc:sqlite:sample.db");
                      ^
  symbol:   variable DriverManager
  location: class Ana
2 errors

SQLException kontrol edilen (checked) bir istisnadır. JDBC metotlarının neredeyse hepsi onu fırlatabileceğini bildirir. Bu yüzden JDBC kodu ya bir try-catch içinde durur ya da metot throws SQLException der. İkisi de yapılmazsa program derlenmez:

Ana.java:4: error: unreported exception SQLException; must be caught or declared to be thrown
    Connection conn = DriverManager.getConnection("jdbc:sqlite:sample.db");
                                                 ^
1 error

Connection, Statement ve ResultSet veri tabanında kaynak tutar: açık bir dosya, kilitler, bellek. Üçü de AutoCloseable arayüzünü uygular ve işleri bitince kapatılmalıdır. En güvenli yol try-with-resources'tır.

11.5.3Bağlantı adresi

DriverManager hangi veri tabanına bağlanılacağını bir metin adresinden anlar. Bu adrese JDBC URL'si denir (Şekil 11.18).

jdbcprotokol:sqlitealt protokol → sürücü:sample.dbveri tabanı (dosya)
Şekil 11.18: JDBC bağlantı adresinin parçaları: protokol (jdbc), alt protokol (sürücüyü seçer) ve veri tabanının dosyası ya da adresi.

Adresin biçimi jdbc:<alt protokol>:<veri tabanı dosyası ya da adresi>'dir. İlk parça her zaman jdbc'dir. İkinci parça sürücüyü belirler: sqlite, mysql, postgresql. DriverManager kayıtlı sürücülere adresi kabul edip etmediklerini sırayla sorar. Adresi kabul eden ilk sürücü bağlantıyı açar. Üçüncü parçanın biçimi sürücüye özgüdür. Tablo 11.14 örnekleri verir.

Tablo 11.14: Bağlantı adresi örnekleri.
Bağlantı adresi (URL)Anlamı
jdbc:sqlite:sample.dbÇalışma klasöründeki sample.db; yoksa boş olarak oluşturulur
jdbc:sqlite:C:/veriler/okul.dbTam yol ile dosya
jdbc:sqlite::memory:Bellekte geçici veri tabanı; bağlantı kapanınca silinir
jdbc:mysql://localhost:3306/testdbMySQL sunucusu: makine, kapı, veri tabanı adı; ayrıca kullanıcı adı ve şifre

Bellekteki veri tabanı denemeler için çok kullanışlıdır: diskte dosya bırakmaz ve her çalıştırmada temiz başlar. Bu notun birçok örneği jdbc:sqlite::memory: ile çalıştırıldı. Ancak bellekteki veri tabanı bağlantıya özgüdür. Bir bağlantıda oluşturulan tablo, aynı adresle açılan ikinci bir bağlantıda yoktur. Denemede ikinci bağlantı no such table: t iletisini aldı.

Göreli bir dosya yolu çalışma klasörüne göre çözülür. Eclipse'te çalışma klasörü proje klasörüdür; komut satırında ise java komutunun verildiği klasördür. Bu yüzden program iki yerden çalıştırılınca iki ayrı sample.db oluşabilir. SQLite olmayan bir dosyayı oluşturur ama olmayan bir klasörü oluşturmaz. jdbc:sqlite:alt/klasor/okul.db adresinde alt klasörü yoksa bağlantı “path … does not exist” iletisiyle başarısız olur.

Eski kitaplarda ve kodlarda bağlantıdan önce şu satır görülür: Class.forName("org.sqlite.JDBC");. Bu satır sürücü sınıfını yükleyip DriverManager'a kaydettirirdi. JDBC 4.0'dan (Java 6) beri sürücü, sınıf yolundaki jar'dan kendiliğinden yüklenir; satır artık gerekmez. Yazılırsa zarar da vermez. Sürücü sınıf yolunda yoksa ne olur? Program sürücü jar'ı olmadan çalıştırıldığında şu çıktı alındı:

No suitable driver found for jdbc:sqlite:sample.db
ClassNotFoundException: org.sqlite.JDBC

İlk satır getConnection'ın fırlattığı SQLException'ın iletisidir: adresi kabul eden sürücü bulunamamıştır. Aynı ileti adreste yazım hatası olduğunda da gelir. Örneğin jdbc:sqlit:okul.db adresi sürücü yüklüyken bile bu iletiyi verir, çünkü sqlit alt protokolünü tanıyan sürücü yoktur. İkinci satır, eski yöntemle sürücü sınıfı aranırken alınan ClassNotFoundException'dır.

11.5.4Bir JDBC programının adımları

Her JDBC programı aynı beş adımı izler (Şekil 11.19): bağlantı açılır, bir komut nesnesi oluşturulur, SQL çalıştırılır, sonuç işlenir ve kaynaklar kapatılır.

1. BağlangetConnection()2. Komut nesnesicreateStatement()3. ÇalıştırexecuteQuery()4. Sonucu işlers.next()5. Kapatclose()
Şekil 11.19: JDBC programının beş adımı: bağlan, komut nesnesi oluştur, çalıştır, sonucu işle, kapat.
try (Connection conn = DriverManager.getConnection(url); // 1
     Statement stmt = conn.createStatement();             // 2
     ResultSet rs = stmt.executeQuery(sql)) {             // 3
  while (rs.next()) {                                     // 4
    System.out.println(rs.getInt(1));
  }
} // 5: rs, stmt, conn ters sırada otomatik kapanır

Kodun yapısı adımlarla birebir örtüşür. Birinci, ikinci ve üçüncü adım try-with-resources parantezinde sırayla yapılır: bağlantı açılır, bağlantıdan bir Statement alınır, Statement sorguyu çalıştırıp bir ResultSet döndürür. Dördüncü adım while (rs.next()) döngüsüdür. Beşinci adım için kod yazılmaz: blok bitince, hata olsa bile, kaynaklar açılışın ters sırasıyla kapanır. Önce rs, sonra stmt, en son conn.

Ters sıra rastgele değildir. ResultSet Statement'a, Statement da Connection'a bağlıdır. Bağlantı önce kapansaydı ötekiler havada kalırdı. Kapanışı doğrulamak için kaynaklar bloğun dışındaki değişkenlere kopyalandı ve blok bittikten sonra isClosed() ile sorgulandı:

7
8
true true true
The database has been closed

Üç kaynak da kapalıdır (true true true). Kapalı Statement ile sorgu çalıştırma denemesi bir SQLException fırlattı; iletisi son satırdadır. Kapatılmayan bağlantılar SQLite dosyasını kilitli bırakabilir. Başka bir program, hatta aynı programın başka bir bağlantısı dosyaya yazamaz. Uzun süre çalışan programlarda kapatılmayan nesneler bellek sızıntısına da yol açar.

Kodda bir ayrıntı daha vardır: rs.getInt(1). Sütunlar adlarıyla okunabildiği gibi sıra numarasıyla da okunabilir. JDBC'de sütun numaraları 1'den başlar. Java dizilerinde 0'dan başlamaya alışkın bir programcı için bu sık bir hata kaynağıdır.

Sık yapılan hata

try-with-resources'ta açılan kaynağı bloğun dışında kullanmaya çalışmak. Parantezde tanımlanan değişkenin kapsamı yalnız bloktur; blok bittiğinde kaynak hem kapanmış hem de görünmez olmuştur. Derleyici dışarıdaki kullanımı reddeder:

Ana.java:6: error: cannot find symbol
    conn.close();
    ^
  symbol:   variable conn
  location: class Ana
1 error

11.6Temel işlemler

Bu kesimdeki altı küçük program, sample.db adlı aynı dosya üzerinde sırayla çalışır: bağlanma, tablo oluşturma, ekleme, okuma, güncelleme, silme. Programlar bağımsızdır; her biri kendi bağlantısını açar ve kapatır. Veri, programlar arasında dosyada kalıcı olarak durur. Her program tam hâliyle public static void main içinde ve import java.sql.*; ile çalıştırıldı; notta yalnız main'in gövdesi verilir.

11.6.1SQLite'a bağlanma

// Veri tabanı dosyasının yolu (bağlantı adresi)
String url = "jdbc:sqlite:sample.db";
try (Connection conn = DriverManager.getConnection(url)) {
  // Bağlantı başarılıysa mesaj yazdırılır.
  if (conn != null) {
    System.out.println("Bağlantı başarılı!");
  }
} catch (SQLException e) {
  // Bağlantı hatası olursa hata mesajı yazdırılır.
  System.out.println(e.getMessage());
}
Bağlantı başarılı!

Program bağlantı adresini bir değişkende tutar ve getConnection ile bağlantı açar. Dosya yoksa SQLite onu oluşturur; ilk çalıştırmadan önce olmayan sample.db, çalıştırmadan sonra diskte durur. İlginç bir ayrıntı: oluşan dosyanın boyu 0 bayttır. SQLite dosyaya ilk tablo yazılana kadar içerik koymaz. Bu, bir File nesnesiyle dosyanın varlığı ve boyu sorgulanarak görüldü.

if (conn != null) denetimi yalnız ek bir güvencedir. getConnection başarısız olursa null döndürmez, SQLException fırlatır. Bu yüzden try bloğunun içine gelindiyse bağlantı kesinlikle açıktır. catch bloğu hatanın iletisini yazar; sürücü sınıf yolunda olmasaydı burada “No suitable driver found …” yazardı.

11.6.2Tablo oluşturma

String url = "jdbc:sqlite:sample.db";
String sql = "CREATE TABLE IF NOT EXISTS users "
           + "(id INTEGER PRIMARY KEY, name TEXT, email TEXT)";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement()) {
  // Tablo oluşturuluyor.
  stmt.execute(sql);
  System.out.println("Tablo oluşturuldu!");
} catch (SQLException e) {
  System.out.println(e.getMessage());
}
Tablo oluşturuldu!

Bu kez try-with-resources parantezinde iki kaynak vardır: bağlantı ve bir Statement. Kaynaklar parantez içinde noktalı virgülle ayrılır. execute(sql) her tür SQL'i çalıştırır. Dönüş değeri, komutun bir sonuç kümesi üretip üretmediğini söyler. CREATE TABLE sonuç kümesi üretmez; execute false döndürür. SELECT için true dönerdi. Programda dönüş değeri kullanılmamıştır; ikisi de ayrı bir denemede yazdırılarak doğrulandı.

IF NOT EXISTS sayesinde program ikinci kez çalıştırıldığında hata vermez. Bu ifade olmasaydı ikinci çalıştırmada şu ileti alınırdı: [SQLITE_ERROR] SQL error or missing database (table users already exists).

11.6.3Veri ekleme

String url = "jdbc:sqlite:sample.db";
String sql = "INSERT INTO users(name, email) "
           + "VALUES('Ahmet Yılmaz', 'ahmet@example.com')";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement()) {
  // Veri tabanına veri ekleniyor.
  int sayi = stmt.executeUpdate(sql);
  System.out.println("Veri eklendi! (" + sayi + " satır)");
} catch (SQLException e) {
  System.out.println(e.getMessage());
}
Veri eklendi! (1 satır)

Ekleme executeUpdate ile yapılır. Bu metot INSERT, UPDATE ve DELETE için kullanılır ve komutun etkilediği satır sayısını int olarak döndürür. Tek bir satır eklendiği için dönen değer 1'dir. id verilmemiştir; SQLite tablo boş olduğu için 1 atar.

SQL metni Java'da iki parça olarak yazılmış ve + ile birleştirilmiştir. Birinci parçanın sonundaki boşluğa dikkat edilmelidir: "INSERT INTO users(name, email) ". Boşluk unutulsaydı metin …email)VALUES(… biçiminde birleşirdi. SQLite bunu kabul ederdi, ama başka bir yerde, örneğin "FROM users" + "WHERE id = 1" birleşiminde, usersWHERE ortaya çıkar ve sorgu bozulur. Metin değerler SQL'in kuralı gereği tek tırnak içindedir.

Program her çalıştığında yeni bir satır eklenir: id 2, 3, … Aynı kişi tabloda birden çok kez bulunur. Bu, sorgu sonuçlarını beklenmedik biçimde değiştirebilir.

11.6.4Veri okuma

String url = "jdbc:sqlite:sample.db";
String sql = "SELECT * FROM users";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
  // Satırlar tek tek okunup yazdırılıyor.
  while (rs.next()) {
    System.out.println("ID: " + rs.getInt("id")
        + ", Ad: " + rs.getString("name")
        + ", E-posta: " + rs.getString("email"));
  }
} catch (SQLException e) {
  System.out.println(e.getMessage());
}
ID: 1, Ad: Ahmet Yılmaz, E-posta: ahmet@example.com

Okuma executeQuery ile yapılır ve bir ResultSet döndürür. Bu kez sonuç kümesi de bir kaynak olduğu için try-with-resources parantezine üçüncü kaynak olarak girer. rs.next() imleci bir sonraki satıra taşır; satır varsa true, yoksa false döndürür. getInt("id") ve getString("name") o anki satırın sütunlarını adlarıyla okur. Tabloda tek satır olduğu için döngü bir kez döner.

Sütun adına göre okumak, sıra numarasına göre okumaktan daha güvenlidir. SELECT * sütunları tablo tanımındaki sırayla getirir; tabloya ileride yeni bir sütun eklenirse numaralar kayabilir, adlar ise değişmez. SQLite sürücüsünde sütun adları büyük-küçük harfe duyarlı değildir: getString("NAME") da çalışır. Olmayan bir sütun adı ise bir SQLException fırlatır: getString("ad") denemesi no such column: 'ad' iletisini verdi.

11.6.5Kod izleme: ResultSet ile okuma

while (rs.next()) döngüsü JDBC'nin en sık görülen kalıbıdır. Aşağıdaki program iki satırlı bir tabloyu okur. okuma.db, programdan önce iki satırla hazırlanmıştır: (1, Ahmet Yılmaz) ve (2, Ayşe Demir).

String url = "jdbc:sqlite:okuma.db";
String sql = "SELECT * FROM users";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) {
    int id = rs.getInt("id");
    String ad = rs.getString("name");
    System.out.println(id + " - " + ad);
  }
  System.out.println("Okuma bitti.");
} catch (SQLException e) {
  System.out.println(e.getMessage());
}
1 - Ahmet Yılmaz
2 - Ayşe Demir
Okuma bitti.
ResultSet rsidname1Ahmet Yılmaz2Ayşe Demirimleç: ilk satırdan öncers ResultSet rsidname1Ahmet Yılmaz2Ayşe Demirimleç: son satırdan sonrars
Şekil 11.20: İmleç başlangıçta ilk satırın önünde (sol), son next() çağrısından sonra son satırın ardında (sağ).
Tablo 11.15: ResultSet ile okumanın izlenmesi.
AdımSatırDeğişkenlerYeni çıktı
13: try (Connection conn = DriverManager.getConnection(url);
4: Statement stmt = conn.createStatement();
5: ResultSet rs = stmt.executeQuery(sql)) {
——
26: while (rs.next()) {rs.next()=true—
37: int id = rs.getInt("id");
8: String ad = rs.getString("name");
rs.next()=true, id=1, ad="Ahmet Yılmaz"—
49: System.out.println(id + " - " + ad);rs.next()=true, id=1, ad="Ahmet Yılmaz"1 - Ahmet Yılmaz
56: while (rs.next()) {rs.next()=true, id=1, ad="Ahmet Yılmaz"—
67: int id = rs.getInt("id");
8: String ad = rs.getString("name");
rs.next()=true, id=2, ad="Ayşe Demir"—
79: System.out.println(id + " - " + ad);rs.next()=true, id=2, ad="Ayşe Demir"2 - Ayşe Demir
86: while (rs.next()) {rs.next()=false—
911: System.out.println("Okuma bitti.");rs.next()=falseOkuma bitti.
1012: } catch (SQLException e) {——

Tablo 11.15 on adımı verir. Birinci adımda bağlantı açılır ve sorgu çalışır; rs iki satırlık sonucu tutar, imleç ilk satırın önündedir (Şekil 11.20, sol). İkinci adımda next() imleci birinci satıra taşır ve true döndürür. Üçüncü ve dördüncü adımda sütunlar o anki satırdan okunur ve yazdırılır. Beşinci adımda imleç ikinci satıra geçer; altıncı ve yedinci adımlar aynı işi ikinci satır için yapar. Sekizinci adımda başka satır yoktur; next() false döndürür ve döngü biter. Dokuzuncu adımda “Okuma bitti.” yazılır. Onuncu adımda try bloğu biter ve rs, stmt, conn bu sırayla kapanır.

next() toplam üç kez çağrılmıştır: iki satır için birer kez ve sonunu anlamak için bir kez. Genel olarak n satırlı bir sonuçta döngü n kez döner, next() n + 1 kez çağrılır. Boş bir sonuçta ilk next() false döner ve döngü gövdesi hiç çalışmaz. Bu, hata değildir: WHERE id = 99 gibi eşleşmeyen bir sorgu boş bir sonuç kümesi döndürür.

Sık yapılan hata

İlk next()'i çağırmadan sütun okumak. İmleç ilk satırın önündedir; okunacak bir satır yoktur. Standart JDBC bu durumda bir SQLException bekler. SQLite sürücüsü 3.40.0.0 ise denemede hata vermeden birinci satırın değerini döndürdü. Bu, bir sürücünün kendi davranışıdır ve başka sürücülerde kod çöker. Tek satırlık sonuçlarda da kalıp if (rs.next()) { … } biçimindedir.

11.6.6Veri güncelleme ve silme

Güncelleme ve silme, eklemeyle aynı kalıbı kullanır; değişen yalnız SQL metnidir.

String url = "jdbc:sqlite:sample.db";
String sql = "UPDATE users SET name = 'Mehmet Akdeniz' WHERE id = 1";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement()) {
  // Veri tabanındaki veri güncelleniyor.
  int sayi = stmt.executeUpdate(sql);
  System.out.println("Veri güncellendi! (" + sayi + " satır)");
} catch (SQLException e) {
  System.out.println(e.getMessage());
}
Veri güncellendi! (1 satır)

Güncellemeden önce okuma programı ID: 1, Ad: Ahmet Yılmaz, … yazıyordu. Güncellemeden sonra aynı program şunu yazar:

ID: 1, Ad: Mehmet Akdeniz, E-posta: ahmet@example.com

SET yalnız yazılan sütunu değiştirir; email aynı kalmıştır. WHERE id = 1 güncellenecek satırı seçer. executeUpdate 1 döndürür, çünkü koşulu tek satır sağlamıştır. Silme programı da aynı yapıdadır:

String url = "jdbc:sqlite:sample.db";
String sql = "DELETE FROM users WHERE id = 1";
try (Connection conn = DriverManager.getConnection(url);
     Statement stmt = conn.createStatement()) {
  // Veri tabanındaki veri siliniyor.
  int sayi = stmt.executeUpdate(sql);
  System.out.println("Veri silindi! (" + sayi + " satır)");
} catch (SQLException e) {
  System.out.println(e.getMessage());
}

Program iki kez çalıştırıldığında çıktı şöyledir:

Veri silindi! (1 satır)
Veri silindi! (0 satır)

İlk çalıştırmada 1 numaralı satır silinir. İkinci çalıştırmada silinecek satır kalmamıştır. Bu bir hata değildir: DELETE koşulu sağlayan sıfır satırı siler ve executeUpdate 0 döndürür. Program yine de “Veri silindi!” yazar ve kullanıcıyı yanıltır. Bu yüzden ileti dönen sayıya göre verilmelidir. WHERE'siz DELETE FROM users ise tablodaki bütün satırları siler. Tablonun kendisi, yani yapısı, kalır; tabloyu tümüyle kaldıran komut DROP TABLE'dır.

11.6.7Üç execute metodu

Statement'ın SQL çalıştıran üç metodu vardır. Tablo 11.16 hangisinin ne zaman kullanılacağını gösterir.

Tablo 11.16: Statement'ın üç çalıştırma metodu.
MetotDöndürürKullanım
executeboolean: sonuç kümesi var mıCREATE TABLE, DROP; türü bilinmeyen SQL
executeUpdateint: etkilenen satır sayısıINSERT, UPDATE, DELETE
executeQueryResultSetSELECT

Yanlış metot seçilirse ne olur? Aşağıdaki deneme her metodu “yanlış” bir komutla çağırır:

query does not return ResultSet
satır sayısı: 1
executeUpdate(SELECT) = 0
execute(INSERT) = false, getUpdateCount = 1
execute(SELECT) = true
execute(CREATE) = false, getUpdateCount = 1
executeUpdate(CREATE) = 0
executeUpdate(DROP) = 0

executeQuery ile INSERT çalıştırmak query does not return ResultSet iletisiyle bir SQLException fırlatır. İkinci satır şaşırtıcıdır: hata alınmasına rağmen satır tabloya eklenmiştir. SQLite sürücüsü komutu önce çalıştırmış, sonuç kümesi olmadığını sonra fark etmiştir. Programın “hata oldu, ekleme yapılmadı” sanması yanlış olur. executeUpdate ile SELECT bu sürücüde hata vermedi ve 0 döndürdü; standart ise bir SQLException bekler. execute her komutu kabul eder ve sonucu sonradan getUpdateCount() ya da getResultSet() ile almayı sağlar. CREATE ve DROP için executeUpdate 0 döndürür. Kısacası doğru metot seçilir; sürücülerin yanlış kullanıma verdiği tepki birbirinden farklıdır.

Altı program sırayla komut satırından çalıştırıldığında ekran şöyle görünür:

> java Baglan
Bağlantı başarılı!
> java TabloOlustur
Tablo oluşturuldu!
> java VeriEkle
Veri eklendi! (1 satır)
> java VeriOku
ID: 1, Ad: Ahmet Yılmaz, E-posta: ahmet@example.com
> java VeriGuncelle
Veri güncellendi! (1 satır)
> java VeriOku
ID: 1, Ad: Mehmet Akdeniz, E-posta: ahmet@example.com
> java VeriSil
Veri silindi! (1 satır)
> java VeriOku
> java VeriSil
Veri silindi! (0 satır)

Bu döküm gerçek çalıştırmayla birebir aynıdır. Silmeden sonraki VeriOku hiçbir şey yazmaz: tablo vardır ama boştur, döngü gövdesi hiç çalışmaz. Son VeriSil 0 satır siler.

11.6.8Kod izleme: executeUpdate'in dönüş değerleri

executeUpdate'in döndürdüğü sayı, programın doğru iletiyi vermesi için gerekli bilgiyi taşır. Aşağıdaki kod bir malzeme tablosuna üç kayıt ekler, ardından bir güncelleme ve bir silme yapar. Ekleme PreparedStatement ile yapılmıştır. Burada bilinmesi gereken, ? işaretlerinin değerlerin yerini tuttuğu ve setString, setInt ile doldurulduğudur.

String[] adlar = {"zımba", "makas", "bant"};
int[] stok = {40, 0, 15};
PreparedStatement ek = conn.prepareStatement(
    "INSERT INTO malzeme(ad, stok) VALUES(?, ?)");
for (int i = 0; i < adlar.length; i++) {
  ek.setString(1, adlar[i]);
  ek.setInt(2, stok[i]);
  ek.executeUpdate();
}
int g = stmt.executeUpdate(
    "UPDATE malzeme SET stok = stok + 10 WHERE stok < 20");
int s = stmt.executeUpdate("DELETE FROM malzeme WHERE stok > 100");
System.out.println(g + " güncellendi, " + s + " silindi");

Program bir bağlantı ve malzeme(id, ad, stok) tablosuyla çalıştırıldığında ekleme döngüsünün her turunda executeUpdate'in döndürdüğü değer de yazdırıldı. Programın son satırı ve tablonun son durumu da çıktıdadır:

tur 0: 1
tur 1: 1
tur 2: 1
2 güncellendi, 0 silindi
1 zımba 40
2 makas 10
3 bant 25
Tablo 11.17: executeUpdate dönüş değerlerinin izlenmesi.
AdımSatırDeğişkenlerYeni çıktı
11: String[] adlar = {"zımba", "makas", "bant"};
2: int[] stok = {40, 0, 15};
3: PreparedStatement ek = conn.prepareStatement(
4: "INSERT INTO malzeme(ad, stok) VALUES(?, ?)");
adlar=[zımba, makas, bant], stok=[40, 0, 15]—
26: ek.setString(1, adlar[i]);
7: ek.setInt(2, stok[i]);
i=0—
38: ek.executeUpdate();i=0, dönen=1—
45: for (int i = 0; i < adlar.length; i++) {
6: ek.setString(1, adlar[i]);
7: ek.setInt(2, stok[i]);
8: ek.executeUpdate();
i=1, dönen=1—
55: for (int i = 0; i < adlar.length; i++) {
6: ek.setString(1, adlar[i]);
7: ek.setInt(2, stok[i]);
8: ek.executeUpdate();
i=2, dönen=1—
610: int g = stmt.executeUpdate(
11: "UPDATE malzeme SET stok = stok + 10 WHERE stok < 20");
g=2—
712: int s = stmt.executeUpdate("DELETE FROM malzeme WHERE stok > 100");g=2, s=0—
813: System.out.println(g + " güncellendi, " + s + " silindi");g=2, s=02 güncellendi, 0 silindi
malzemeidadstok1zımba402makas103bant25WHERE stok < 20: 2 satır
Şekil 11.21: WHERE stok < 20 makas (0) ve bant (15) satırlarını seçer; ikisine 10 eklenir.

Ekleme komutu döngüden önce bir kez hazırlanır; her turda yalnız değerler değişir. Her ekleme 1 döndürür ve SQLite satırlara sırayla 1, 2 ve 3 numaralarını verir. Güncellemede WHERE stok < 20 koşulunu makas (0) ve bant (15) sağlar (Şekil 11.21). İkisinin stoğuna 10 eklenir: makas 10, bant 25 olur. g 2'dir. SET stok = stok + 10, sütunun eski değerini kullanarak yeni değeri hesaplar; Java'daki stok += 10'un karşılığıdır.

Silmede stoğu 100'den büyük ürün yoktur. Hata oluşmaz, yalnız 0 döner. Program bu sayılara bakarak “kayıt bulunamadı” gibi doğru iletiler verebilir. Bir maliyet notu: WHERE koşulu indekslenmemiş bir sütundaysa (stok gibi) güncelleme ve silme bütün tabloyu tarar; n satırlı tabloda maliyet n ile orantılıdır. WHERE id = 1 gibi birincil anahtar koşulunda ise satır doğrudan bulunur.

11.7PreparedStatement

Şimdiye kadarki SQL metinleri sabitti. Gerçek programlarda ise değerler kullanıcıdan gelir: aranacak ad, eklenecek not, silinecek kayıt numarası. Bu değerleri SQL metnine yerleştirmenin bir yanlış, bir de doğru yolu vardır.

11.7.1SQL enjeksiyonu

En kolay görünen yol, kullanıcının yazdığı değeri dizgi birleştirmeyle SQL metnine yapıştırmaktır. Bir arama ekranında kullanıcı aranacak adı yazar ve program şu metni kurar: "SELECT ad FROM kisiler WHERE ad = '" + girdi + "'". Girdi Ali olduğu sürece her şey yolundadır. Ama girdi bir tırnak işareti içerirse SQL metninin yapısı değişir.

Tanım 11.10

SQL enjeksiyonu (SQL injection), kullanıcı girdisinin SQL komutuna karışıp komutun anlamını değiştirmesi saldırısıdır. Girdi, değer olarak değil SQL'in parçası olarak yorumlanır.

String girdi = "x' OR '1'='1";   // kullanıcının yazdığı "ad"

// TEHLİKELİ: girdi SQL metnine yapıştırılıyor
String sql = "SELECT ad FROM kisiler WHERE ad = '"
           + girdi + "'";
System.out.println(sql);
ResultSet rs1 = stmt.executeQuery(sql);

// GÜVENLİ: girdi yalnızca değer olarak gönderiliyor
PreparedStatement ps = conn.prepareStatement(
    "SELECT ad FROM kisiler WHERE ad = ?");
ps.setString(1, girdi);
ResultSet rs2 = ps.executeQuery();

Kodun birinci yarısı tehlikeli, ikinci yarısı güvenli yoldur. kisiler tablosunda Ali, Ayşe ve Can adlı üç kişi varken iki sonuç kümesinin satırları sayıldı:

SELECT ad FROM kisiler WHERE ad = 'x' OR '1'='1'
Birleştirme: 3 satır
PreparedStatement: 0 satır
girdi uzunluğu: 12
[SQLITE_ERROR] SQL error or missing database (near "nin": syntax error)
PreparedStatement ile: 1
Ali'nin
INSERT INTO kisiler(ad) VALUES('Ali''nin')
tablo duruyor: 4

Çıktının ilk satırı veri tabanına giden SQL metnidir. Kullanıcı ad yerine x' OR '1'='1 yazmıştır. Birleştirmede girdinin içindeki ilk tırnak, programın açtığı tırnağı kapatır. Ad değeri 'x' olarak biter. Ardından gelen OR '1'='1' artık bir değer değil, SQL'in parçasıdır. Programın en sona eklediği tırnak da '1'i kapatır ve metin sözdizimi açısından kusursuz olur. Koşul ad = 'x' OR '1'='1''dir; '1'='1' her satırda doğru olduğu için koşul hiçbir satırı elemez. Üç satır döner.

Bir giriş ekranında bu, şifresiz oturum açmak demektir. Daha kötüsü de mümkündür: girdi verileri silen ya da başka tablolardaki şifreleri okuyan bir SQL parçası olabilir. SQL enjeksiyonu yıllardır web uygulamalarının en yaygın güvenlik açıkları arasındadır.

İkinci yarıdaki PreparedStatement aynı girdiyle 0 satır döndürür. Burada SQL metni sabittir ve girdinin yeri ? ile işaretlenmiştir. Girdi setString(1, girdi) ile ayrıca gönderilir ve yalnız bir değer olarak kullanılır. Veri tabanı “adı tam olarak x' OR '1'='1 olan kişiyi” arar. Bu 12 karakterlik metin hiç kimsenin adı değildir.

Çıktının son satırları iki yan sonucu daha gösterir. Ali'nin gibi kesme işaretli bir metin birleştirmeyle eklenmek istendiğinde SQL bozulur ve near "nin": syntax error iletisi alınır. Saldırı amacı olmayan sıradan bir kullanıcı bile programı bozabilir. PreparedStatement ile aynı metin sorunsuz eklenir ve geri okunur. Tırnağı elle ikilemek ('') de bir yoldur ama hataya açıktır; tek bir unutulan yer bütün programı savunmasız bırakır. SQLite sürücüsünün executeQuery'si noktalı virgülle eklenen ikinci bir komutu çalıştırmadı (DROP TABLE denemesinden sonra tablo duruyordu). Bu bir koruma sayılmaz: tek bir komutun içinde kalan saldırı, yukarıdaki gibi, yine işler.

11.7.2Kod izleme: SQL enjeksiyonu

Aşağıdaki kod iki yolu satır sayısıyla karşılaştırır:

String girdi = "x' OR '1'='1";
String sql = "SELECT ad FROM kisiler WHERE ad = '"
           + girdi + "'";
int n = 0;
try (ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) n++;
}
System.out.println("Birleştirme: " + n + " satır");
PreparedStatement ps = conn.prepareStatement(
    "SELECT ad FROM kisiler WHERE ad = ?");
ps.setString(1, girdi);
n = 0;
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) n++; }
System.out.println("PreparedStatement: " + n + " satır");
veri tabanına giden SQL:SELECT ad FROM kisilerWHERE ad = 'x' OR '1'='1'idad1Ali2Ayşe3Cankoşul'Ali'='x' → yanlış OR '1'='1' → DOĞRU'Ayşe'='x' → yanlış OR doğru → DOĞRU'Can'='x' → yanlış OR doğru → DOĞRU3 satır döndü veri tabanına giden SQL:SELECT ad FROM kisilerWHERE ad = [x' OR '1'='1]idad1Ali2Ayşe3Cankoşulad = "x' OR '1'='1" → yanlışad = "x' OR '1'='1" → yanlışad = "x' OR '1'='1" → yanlış0 satır
Şekil 11.22: Birleştirilmiş SQL'de koşul her satırda doğrudur ve 3 satır döner (sol); PreparedStatement'ta girdi tek bir metin değeridir ve hiçbir adla eşleşmez (sağ).
Tablo 11.18: SQL enjeksiyonunun izlenmesi.
AdımSatırDeğişkenlerYeni çıktı
11: String girdi = "x' OR '1'='1";girdi=x' OR '1'='1—
22: String sql = "SELECT ad FROM kisiler WHERE ad = '"
3: + girdi + "'";
sql=SELECT … WHERE ad = 'x' OR '1'='1'—
35: try (ResultSet rs = stmt.executeQuery(sql)) {rs=ResultSet—
45: try (ResultSet rs = stmt.executeQuery(sql)) {rs=ResultSet—
56: while (rs.next()) n++;
7: }
n=3—
68: System.out.println("Birleştirme: " + n + " satır");n=3Birleştirme: 3 satır
79: PreparedStatement ps = conn.prepareStatement(
10: "SELECT ad FROM kisiler WHERE ad = ?");
ps=PreparedStatement—
811: ps.setString(1, girdi);1. ?=x' OR '1'='1—
913: try (ResultSet rs = ps.executeQuery()) { while (rs.next()) n++; }n=0—
1014: System.out.println("PreparedStatement: " + n + " satır");n=0PreparedStatement: 0 satır

Üçüncü ve dördüncü adımlarda veri tabanı koşulu her satır için değerlendirir. Ali için 'Ali'='x' yanlıştır ama OR'un sağ tarafı her zaman doğrudur; satır seçilir. Ayşe ve Can da aynı nedenle seçilir (Şekil 11.22, sol). Yedinci adımda SQL önce derlenir; ? yalnız bir değerin yeridir. Komutun yapısı bu noktada kesinleşmiştir ve girdi ne olursa olsun değişemez. Sekizinci adımda girdi bütünüyle tek bir metin değeri olarak bağlanır; içindeki tırnaklar sıradan karakterdir. Dokuzuncu adımda hiçbir ad bu metne eşit değildir. Aynı girdi iki farklı sonuç verir. Kural kesindir: kullanıcıdan gelen değer SQL'e her zaman ? ile verilir.

11.7.3Yer tutucular

Tanım 11.11

Önceden hazırlanan deyim (PreparedStatement), değerlerin yerine ? yer tutucuları içeren ve değerleri ayrıca alan SQL deyimidir. Bağlantının prepareStatement(sql) metoduyla oluşturulur; yer tutucular setXxx(sıra, değer) metotlarıyla doldurulur.

Aşağıdaki metot bir kişiyi kisiler tablosuna ekler:

static void ekle(Connection conn, String ad, String soyad,
                 int yas) throws SQLException {
  String sql = "INSERT INTO kisiler(ad, soyad, yas) "
             + "VALUES(?, ?, ?)";
  try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, ad);      // 1. soru işareti
    ps.setString(2, soyad);   // 2. soru işareti
    ps.setInt(3, yas);        // 3. soru işareti
    ps.executeUpdate();
  }
}

SQL metninde üç ? vardır. setXxx metotlarının ilk argümanı yer tutucunun sırasıdır; sıra 1'den başlar ve SQL metnindeki soldan sağa sırayı izler (Şekil 11.23). Metodun adı değerin Java tipini söyler: metin için setString, tam sayı için setInt, ondalıklı sayı için setDouble. Bütün yer tutucular doldurulduktan sonra executeUpdate() parametresiz çağrılır, çünkü SQL metni hazırlanırken verilmiştir.

INSERT INTO kisiler(ad, soyad, yas) VALUES( ?, ?, ? )?1. yer tutucu?2. yer tutucu?3. yer tutucusetString(1, ad)"Ahmet"setString(2, soyad)"Yılmaz"setInt(3, yas)30
Şekil 11.23: setString ve setInt çağrıları soru işaretlerini sırayla, 1'den başlayan numaralarla doldurur.

PreparedStatement'ın üç yararı vardır. Birincisi güvenliktir: SQL enjeksiyonunu önler. İkincisi tırnak sorunu yoktur: "Ali'nin" sorunsuz kaydedilir. Üçüncüsü yeniden kullanımdır: aynı komut bir kez hazırlanır ve farklı değerlerle tekrar tekrar çalıştırılır. VTYS komutu her seferinde yeniden çözümlemez; döngüde binlerce ekleme yapılırken bu fark hissedilir.

Yer tutucular yalnız değerlerin yerini tutabilir. Tablo ya da sütun adı ? ile verilemez: SELECT * FROM ? hazırlanırken near "?": syntax error iletisi alındı. Tırnak içindeki '?' ise bir yer tutucu değil, soru işareti karakteridir; WHERE ad = '?' sorgusunda setString(1, …) çağrısı başarısız olur. Doldurulmayan bir yer tutucu NULL sayılır. Üç yer tutuculu ekleme yalnız iki değer verilerek çalıştırıldığında NOT NULL constraint failed: kisiler.yas iletisi alındı. Bir başka ayrıntı: doldurulan değer, yeni bir setXxx çağrısına kadar yerinde kalır. Bir eklemeden sonra yalnız setString(1, "Bora") değiştirilip yeniden çalıştırıldığında, ikinci satır öncekinin soyadını ve yaşını aldı.

Sık yapılan hata

Yer tutucu sırasını 0'dan başlatmak: ps.setInt(0, 5). Standart bu durumda bir SQLException bekler. SQLite sürücüsü 3.40.0.0 ise denetlenmeyen bir ArrayIndexOutOfBoundsException fırlattı (Index -1 out of bounds for length 3). Bu istisna catch (SQLException e) ile yakalanmaz ve programı durdurur. Sıra her zaman 1'den başlar ve ? sayısını aşmaz.

11.7.4Kişi programı

Ekleme metodu, güncelleme, silme ve listeleme metotlarıyla birlikte küçük ama tam bir program oluşturur. Güncelleme ve silme metotları şöyledir:

static void guncelle(Connection conn, String ad, String soyad,
                     int yas) throws SQLException {
  String sql = "UPDATE kisiler SET soyad = ?, yas = ? "
             + "WHERE ad = ?";
  try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, soyad);
    ps.setInt(2, yas);
    ps.setString(3, ad);
    ps.executeUpdate();
  }
}

static void sil(Connection conn, String ad) throws SQLException {
  String sql = "DELETE FROM kisiler WHERE ad = ?";
  try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, ad);
    ps.executeUpdate();
  }
}

Her metot Connection'ı parametre olarak alır. Bağlantı main'de bir kez açılır ve birçok işte kullanılır. Her işlem için yeni bağlantı açmak hem yavaştır hem de gereksizdir. PreparedStatement ise her metodun kendi try-with-resources bloğunda açılır ve metot bitince kapanır; bağlantı açık kalır.

Güncelleme metodunda ?'lerin sırasına dikkat edilmelidir. SQL metni SET soyad = ?, yas = ? WHERE ad = ?'dir. Metodun parametre sırası (ad, soyad, yas) olsa da SQL'deki sırada ad üçüncü yer tutucudur. Bu yüzden setString(3, ad) yazılır. Numaralar Java parametrelerine göre değil, SQL metnine göre verilir.

Metotlar throws SQLException ile hatayı çağırana iletir. main de throws SQLException dediği için bir hata olursa program yığın iziyle durur. Küçük bir örnek program için bu kabul edilebilir. Gerçek bir programda hata main'de yakalanır ve kullanıcıya anlaşılır bir ileti verilir. Listeleme metodu şöyledir:

static void listele(Connection conn) throws SQLException {
  String sql = "SELECT id, ad, soyad, yas FROM kisiler";
  try (Statement st = conn.createStatement();
       ResultSet rs = st.executeQuery(sql)) {
    while (rs.next()) {
      System.out.println(rs.getInt("id") + " " + rs.getString("ad")
          + " " + rs.getString("soyad") + " " + rs.getInt("yas"));
    }
    System.out.println("---");
  }
}

Bu sorguda değişken bir değer yoktur; düz Statement yeterlidir. Kullanıcı girdisi olmadığı için enjeksiyon riski de yoktur. Her listele çağrısı tablonun o anki hâlini okur ve sonuna --- yazar. Son olarak main metotları sırayla çağırır:

public static void main(String[] args) throws SQLException {
  String url = "jdbc:sqlite:kisiler.db";
  try (Connection conn = DriverManager.getConnection(url);
       Statement stmt = conn.createStatement()) {
    stmt.execute("CREATE TABLE IF NOT EXISTS kisiler ("
        + "id INTEGER PRIMARY KEY, ad TEXT NOT NULL, "
        + "soyad TEXT NOT NULL, yas INTEGER NOT NULL)");
    ekle(conn, "Ahmet", "Yılmaz", 30);
    ekle(conn, "Ayşe", "Demir", 40);
    guncelle(conn, "Ahmet", "Akdeniz", 35);
    listele(conn);
    sil(conn, "Ahmet");
    listele(conn);
  }
}

Dört metot ve main tek bir Kisi sınıfında birleştirilip boş bir klasörde çalıştırıldı:

1 Ahmet Akdeniz 35
2 Ayşe Demir 40
---
2 Ayşe Demir 40
---

11.7.5Kod izleme: kişi programı

Tablo 11.19: Kişi programının izlenmesi (satır numaraları main'e göredir).
AdımSatırDeğişkenlerYeni çıktı
13: try (Connection conn = DriverManager.getConnection(url);
4: Statement stmt = conn.createStatement()) {
——
25: stmt.execute("CREATE TABLE IF NOT EXISTS kisiler ("
6: + "id INTEGER PRIMARY KEY, ad TEXT NOT NULL, "
7: + "soyad TEXT NOT NULL, yas INTEGER NOT NULL)");
——
38: ekle(conn, "Ahmet", "Yılmaz", 30);——
49: ekle(conn, "Ayşe", "Demir", 40);——
510: guncelle(conn, "Ahmet", "Akdeniz", 35);——
611: listele(conn);—1 Ahmet Akdeniz 35 ⏎ 2 Ayşe Demir 40 ⏎ ---
712: sil(conn, "Ahmet");——
813: listele(conn);—2 Ayşe Demir 40 ⏎ ---
914: }——
kisileridadsoyadyas1AhmetAkdeniz352AyşeDemir40 kisileridadsoyadyas2AyşeDemir40
Şekil 11.24: Güncellemeden sonra kisiler tablosu (sol) ve silmeden sonra (sağ).

Birinci adımda kisiler.db yoksa oluşturulur; bağlantı ve stmt açılır. İkinci adımda tablo oluşturulur ve henüz boştur. Üçüncü ve dördüncü adımlarda ekle çağrılır; Ahmet id = 1, Ayşe id = 2 ile eklenir. Beşinci adımda WHERE ad = ? Ahmet'i seçer; soyadı Akdeniz, yaşı 35 olur. Altıncı adımda listele iki satırı ve ---'yü yazar. Yedinci adımda Ahmet'in satırı silinir. Sekizinci adımda tabloda yalnız Ayşe kalmıştır. Dokuzuncu adımda bağlantı kapanır; veri kisiler.db'de kalıcıdır (Şekil 11.24).

Bir maliyet notu eklenebilir. guncelle ve sil, WHERE ad = ? ile çalışır. ad sütununda indeks olmadığından her çağrı tabloyu tarar. Ayrıca aynı adlı iki kişi varsa ikisini birden günceller ya da siler. Birincil anahtarla (WHERE id = ?) çalışmak hem hızlı hem de kesindir.

11.8İleri JDBC

11.8.1Otomatik anahtar: getGeneratedKeys

Yeni satırın id'sini veri tabanı belirler. Program çoğu zaman bu numarayı hemen bilmek ister. Bir sipariş eklendiğinde siparişin kalemleri ayrı bir tabloya, siparişin id'siyle eklenir. Bu numarayı öğrenmenin yolu getGeneratedKeys()'tir:

String sql = "INSERT INTO ogrenciler(ad, bolum_id) "
    + "VALUES(?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql,
        Statement.RETURN_GENERATED_KEYS)) {
  ps.setString(1, "Ece");
  ps.setInt(2, 3);
  ps.executeUpdate();
  try (ResultSet k = ps.getGeneratedKeys()) {
    if (k.next()) {
      System.out.println("Yeni id: " + k.getInt(1));
    }
  }
}
Yeni id: 5
id INTEGER PRIMARY KEY -> 3
id INTEGER PRIMARY KEY AUTOINCREMENT -> 4
ortadan silince: 1,3,4
last_insert_rowid: 5

prepareStatement'a ikinci argüman olarak Statement.RETURN_GENERATED_KEYS verilir. Bu sabit, sürücüye oluşturulan anahtarları saklamasını söyler. executeUpdate'ten sonra getGeneratedKeys() bir ResultSet döndürür. Bu sonuç kümesinin tek satırı ve tek sütunu yeni anahtardır. Tabloda dört öğrenci (Ada, Bora, Cem, Deniz) vardı; Ece 5 numarasını aldı. Yeni anahtar da bir sonuç kümesi olduğu için try-with-resources ile açılıp kapanır.

Çıktının sonraki iki satırı numaralandırmanın bir inceliğini gösterir. Bir tabloya 1, 2, 3 numaralı satırlar eklenir, 3 silinir ve yeni bir satır eklenir. Yeni satır hangi numarayı alır? Tablo 11.20 iki tanımı karşılaştırır.

Tablo 11.20: 1, 2, 3 eklendi, 3 silindi; yeni satırın id'si.
Sütun tanımıYeni id
id INTEGER PRIMARY KEY3 (en büyük id + 1)
id INTEGER PRIMARY KEY AUTOINCREMENT4 (kullanılmış numara verilmez)

Yalın INTEGER PRIMARY KEY, en büyük mevcut numaranın bir fazlasını verir. En büyük numara silinirse o numara yeniden kullanılır. Ortadan bir numara silinirse (örneğin 2) yeni satır yine 4 alır; boşluk doldurulmaz. AUTOINCREMENT ise o güne kadar kullanılmış en büyük numarayı ayrı bir tabloda saklar ve hiçbir numarayı yeniden vermez. Silinen kaydın numarası başka bir kayda geçmemeliyse (fatura numarası, sipariş numarası) AUTOINCREMENT kullanılır. MySQL'deki karşılığı AUTO_INCREMENT'tir.

11.8.2Satırdan nesneye: DAO deseni

Şimdiye kadarki programlar SQL'i ve satır okumayı main'in içine dağıtıyordu. Nesne yönelimli bir programda ise her satır bir nesneye dönüştürülür ve SQL tek bir yerde toplanır. Öğrenci satırları için basit bir sınıf yeterlidir:

class Ogrenci {
  private final int id;
  private final String ad;
  Ogrenci(int id, String ad) {
    this.id = id;
    this.ad = ad;
  }
  public String toString() {
    return "#" + id + " " + ad;
  }
}

Ogrenci sınıfının alanları final'dır; nesne oluşturulduktan sonra değişmez. toString, nesneyi #1 Ada biçiminde yazar. Bu sınıf veri tabanından habersizdir: içinde ne SQL ne de JDBC vardır.

Tanım 11.12

Veri erişim nesnesi (DAO; data access object), bir tablonun bütün SQL işlemlerini tek bir sınıfta toplayıp sonuçları nesne olarak döndüren tasarımdır. Programın geri kalanı SQL görmez; yalnız DAO'nun metotlarını çağırır.

Tablo 11.21 bir OgrenciDao sınıfının metotlarını verir. Her metot bir SQL komutuna karşılık gelir ve sonucu Java'nın diliyle döndürür: satır yerine nesne, sonuç kümesi yerine liste, etkilenen satır sayısı yerine boolean.

Tablo 11.21: OgrenciDao sınıfının metotları.
OgrenciDao metoduSQLDöndürür
ekle(ad, bolum)INSERTyeni id
bul(id)SELECT … WHERE id = ?Ogrenci ya da null
bolumdekiler(bolum)SELECT … WHERE bolum_id = ?List<Ogrenci>
sil(id)DELETEsilindi mi (boolean)
main, arayüzList<Ogrenci> ile çalışırOgrenciDaoSQL yalnız buradaJDBC + sürücüConnection, PreparedStatementokul.dbtablolaristek ↓↑ nesneler
Şekil 11.25: Katmanlar: arayüz, OgrenciDao, JDBC ve veri tabanı dosyası. İstekler aşağı, nesneler yukarı gider.

Şekil 11.25 katmanlı yapıyı gösterir. Kullanıcı arayüzü (main ya da bir Swing penceresi) yalnız OgrenciDao'yu tanır ve ondan List<Ogrenci> ister. DAO, JDBC ve SQL ile çalışır. SQL yalnız bu sınıftadır. Veri tabanı değişirse ya da tablo yeniden düzenlenirse yalnız DAO değişir; arayüz kodu aynı kalır. Bu, sarmalamanın veri tabanı düzeyindeki karşılığıdır. Bağlantı DAO'ya yapıcıyla verilir; DAO bağlantıyı açmaz ve kapatmaz.

Tablodaki dört metodun tamamı yazılıp çalıştırıldı. Ada, Bora, Cem ve Deniz'in bulunduğu tabloda sonuçlar şöyledir:

ekle -> 5
bul(2) -> #2 Bora
bul(9) -> null
bolumdekiler(1) -> [#1 Ada, #3 Cem, #5 Ece]
sil(3) -> true
sil(3) -> false
bolumdekiler(1) -> [#1 Ada, #5 Ece]

ekle, getGeneratedKeys ile Ece'nin numarasını (5) döndürür. bul(9) satır bulamaz ve null döndürür; çağıran kod bunu denetlemelidir. sil(3) ilk çağrıda true, ikinci çağrıda false döndürür, çünkü executeUpdate ikinci kez 0 verir. DAO bu sayıyı boolean'a çevirerek programın geri kalanını SQL'in ayrıntısından korur.

Satırları nesnelere eşleme işi bu örnekte elle yapılmıştır. Büyük projelerde bu işi ORM (object-relational mapping, nesne-ilişkisel eşleme) kütüphaneleri, örneğin JPA standardı ve onun bir gerçeklemesi olan Hibernate, kendiliğinden yapar. Sınıf alanlarının hangi sütunlara karşılık geldiği açıklamalarla bildirilir; SQL'in çoğunu kütüphane kendisi üretir. ORM'nin arkasında yine JDBC çalışır; bu bölümde öğrenilenler o kütüphaneleri anlamanın da temelidir.

11.8.3Kod izleme: satırdan nesneye

Aşağıdaki metot bir bölümdeki öğrencileri nesne listesi olarak döndürür; son iki satır onu main'den çağırır:

static List<Ogrenci> bolumdekiler(Connection conn,
    int bolum) throws SQLException {
  List<Ogrenci> liste = new ArrayList<>();
  String sql = "SELECT id, ad FROM ogrenciler "
      + "WHERE bolum_id = ? ORDER BY id";
  try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, bolum);
    try (ResultSet rs = ps.executeQuery()) {
      while (rs.next()) {
        liste.add(new Ogrenci(rs.getInt("id"),
                              rs.getString("ad")));
      }
    }
  }
  return liste;
}
// main içinde:
List<Ogrenci> liste = bolumdekiler(conn, 1);
for (Ogrenci o : liste) System.out.println(o);
#1 Ada
#3 Cem
bölüm 3: []
bölüm 2: [#2 Bora]
Tablo 11.22: bolumdekiler metodunun izlenmesi.
AdımSatırÇağrı yığınıDeğişkenlerYeni çıktı
118: List<Ogrenci> liste = bolumdekiler(conn, 1);main() → bolumdekiler(conn, 1)bolum=1—
23: List<Ogrenci> liste = new ArrayList<>();
4: String sql = "SELECT id, ad FROM ogrenciler "
5: + "WHERE bolum_id = ? ORDER BY id";
6: try (PreparedStatement ps = conn.prepareStatement(sql)) {
main() → bolumdekiler(conn, 1)bolum=1, liste=[]—
37: ps.setInt(1, bolum);
8: try (ResultSet rs = ps.executeQuery()) {
main() → bolumdekiler(conn, 1)bolum=1, liste=[]—
49: while (rs.next()) {
10: liste.add(new Ogrenci(rs.getInt("id"),
11: rs.getString("ad")));
main() → bolumdekiler(conn, 1)liste=[#1 Ada]—
59: while (rs.next()) {
10: liste.add(new Ogrenci(rs.getInt("id"),
11: rs.getString("ad")));
main() → bolumdekiler(conn, 1)liste=[#1 Ada, #3 Cem]—
69: while (rs.next()) {
13: }
14: }
main() → bolumdekiler(conn, 1)liste=[#1 Ada, #3 Cem]—
715: return liste;
18: List<Ogrenci> liste = bolumdekiler(conn, 1);
main()liste=[#1 Ada, #3 Cem]—
819: for (Ogrenci o : liste) System.out.println(o);main()liste=[#1 Ada, #3 Cem]#1 Ada ⏎ #3 Cem
rs (bölüm 1)idad1Ada3Cemimleçliste (List<Ogrenci>)Ogrenci #1 AdaOgrenci #3 Cem
Şekil 11.26: İmleç ikinci satırdayken (3, Cem) okunur; listede iki Ogrenci nesnesi vardır.

main metodu bolum = 1 ile çağırır; bağlantı main'de açıktır. Metot boş bir liste oluşturur ve sorguyu ? ile hazırlar. ? = 1 bağlanır ve sorgu çalışır; bölüm 1'de Ada ve Cem vardır. İlk satırdan id = 1, ad = Ada okunur ve new Ogrenci(1, "Ada") listeye eklenir. İkinci satırdan ikinci nesne oluşur: #3 Cem (Şekil 11.26). next() false döndürünce iç içe iki try bloğu biter; önce rs, sonra ps kapanır. Bağlantıyı onu açan main kapatır. Metot iki öğeli listeyi döndürür ve çerçevesi yığından kalkar. main her nesneyi toString() ile yazar; SQL'den habersizdir.

Çıktının son iki satırı aynı metodun öteki bölümler için sonuçlarıdır. 3 numaralı bölümde (Makine) hiç öğrenci yoktur; metot null değil, boş liste döndürür ve [] yazılır. Boş liste döndürmek null'dan daha güvenlidir: çağıran kod bir for döngüsüyle listeyi dolaşırsa döngü hiç dönmez, NullPointerException riski oluşmaz. Metodun maliyeti sorgunun maliyeti ile k nesne oluşturmanın toplamıdır; k, bölümdeki öğrenci sayısıdır. Liste k nesne kadar bellek kaplar. Çok büyük sonuçlar için bütün satırları listeye doldurmak yerine satır satır işlemek gerekebilir.

11.8.4Aynı kod, başka veri tabanı: MySQL

JDBC'nin vaadi, aynı kodun başka veri tabanlarında da çalışmasıdır. Aşağıdaki program bir MySQL sunucusundaki users tablosunu okur:

String url = "jdbc:mysql://localhost:3306/testdb";
String kullanici = "root";
String sifre = "gizli";
String sql = "SELECT id, name, email FROM users";
try (Connection conn =
         DriverManager.getConnection(url, kullanici, sifre);
     Statement stmt = conn.createStatement();
     ResultSet rs = stmt.executeQuery(sql)) {
  while (rs.next()) {
    System.out.println(rs.getInt("id") + " "
        + rs.getString("name"));
  }
} catch (SQLException e) {
  System.out.println(e.getMessage());
}

JDBC kodu SQLite örneğiyle aynıdır. Değişenler dört şeydir: bağlantı adresi, kullanıcı adı, şifre ve sınıf yolundaki sürücü jar'ı. MySQL'in sürücüsü mysql-connector-j'dir. Adres jdbc:mysql://localhost:3306/testdb biçimindedir: localhost sunucunun çalıştığı makine, 3306 MySQL'in varsayılan kapısı, testdb veri tabanının adıdır. getConnection'ın üç argümanlı biçimi kullanıcı adı ve şifreyi de alır.

MySQL bir sunucudur. Program çalışmadan önce sunucu kurulmuş ve çalışıyor olmalı, testdb veri tabanı ve users tablosu oluşturulmuş olmalıdır. SQLite'taki gibi “dosya yoksa oluştur” davranışı yoktur. Bu notun hazırlandığı makinede MySQL sürücüsü yoktu. Program derlendi ve çalıştırıldığında No suitable driver found for jdbc:mysql://localhost:3306/testdb iletisini verdi; bu ileti beklenen davranıştır.

Sürücü sınıfının adı com.mysql.cj.jdbc.Driver'dır. Eski kodlarda görülen com.mysql.jdbc.Driver adı kullanımdan kalkmıştır. JDBC 4.0'dan beri sürücü kendiliğinden yüklendiği için bu ad kodda zaten yazılmaz. Son bir uyarı: gerçek projelerde şifre koda yazılmaz. Koda yazılan şifre derlenmiş .class dosyasında ve sürüm denetim sisteminde açıkça görünür. Şifre bir ayar dosyasından ya da ortam değişkeninden, örneğin System.getenv("DB_SIFRE") ile, okunur.

11.9İşlemler

Şimdiye kadar her SQL komutu tek başına çalıştı ve hemen kalıcı oldu. Bazı işler ise birden çok komuttan oluşur ve bu komutlar ancak birlikte anlam taşır. Bu kesim böyle işlerin nasıl güvenle yürütüleceğini anlatır.

11.9.1İşlem ve ACID

Bir havale düşünülsün: Ali, Ece'ye 300 TL gönderiyor. Bu iş iki UPDATE komutudur: Ece'nin hesabına 300 TL eklenir, Ali'nin hesabından 300 TL düşülür. Birinci komut çalıştıktan sonra elektrik kesilirse, ya da ikinci komut bir hata yüzünden çalışmazsa ne olur? Ece'nin parası artmış, Ali'ninki azalmamıştır: para yoktan var olmuştur. Komutların sırası ters olsaydı para kaybolurdu. İki komuttan birinin olup ötekinin olmaması kabul edilemez.

Tanım 11.13

İşlem (transaction), tek bir iş birimi olarak yürütülen bir ya da daha çok SQL komutudur. İşlemin komutları ya hepsi birlikte kalıcı olur ya da hiçbiri olmaz. Kalıcı yapmaya commit, geri almaya rollback denir.

JDBC'de bağlantının varsayılan ayarı otomatik onaydır (auto-commit): her komut kendi başına bir işlemdir ve çalışır çalışmaz kalıcı olur. Bu ayar SQLite bağlantısında conn.getAutoCommit() ile sorgulandı ve true çıktı. Birden çok komutu tek işlemde toplamanın kalıbı bellidir. conn.setAutoCommit(false) ile işlem başlatılır ve komutlar çalıştırılır. Her şey yolundaysa conn.commit() değişiklikleri kalıcı yapar; bir hata olursa conn.rollback() geri alır (Şekil 11.27).

setAutoCommit(false)UPDATE (+300)UPDATE (−300)commit(): kalıcırollback(): hiçbiri
Şekil 11.27: İşlem: setAutoCommit(false)'tan sonraki komutlar geçicidir; commit() kalıcı yapar, rollback() geri alır.

Güvenilir bir işlemin dört özelliği vardır; baş harflerinden ACID diye anılır.

  • Bölünmezlik (atomicity): işlemin ya tamamı ya hiçbiri uygulanır. Havalenin iki komutundan yalnız biri kalıcı olamaz.
  • Tutarlılık (consistency): işlem sonunda bütün kısıtlar sağlanır. CHECK (bakiye >= 0) varsa hiçbir işlem bir bakiyeyi eksiye düşürerek bitemez.
  • Yalıtım (isolation): eş zamanlı işlemler birbirinin yarım hâlini görmez. Havale sürerken başka bir program Ece'nin bakiyesini okursa ya havaleden önceki ya da sonraki değeri görür, aradaki değeri görmez.
  • Kalıcılık (durability): commit edilen veri, hemen ardından elektrik kesilse de kaybolmaz. VTYS commit'i, değişiklik diske güvenle yazıldıktan sonra tamamlar.

Yalıtım bir denemeyle görülebilir. Aynı banka.db dosyasına iki ayrı bağlantı açıldı. Birinci bağlantı otomatik onayı kapatıp Ece'nin bakiyesini 500'den 800'e çıkardı, henüz commit etmedi. Bu sırada iki bağlantı aynı satırı okudu:

a görür: 800
b görür: 500
commit sonrası b görür: 800

Değişikliği yapan bağlantı kendi geçici değerini (800) görür. İkinci bağlantı ise commit edilmemiş değişikliği görmez ve 500 okur. Commit'ten sonra ikinci bağlantı da 800 görür.

11.9.2Havale örneği

Havale programı iki parçadır. main bellekte bir veri tabanı kurar, iki hesap ekler ve iki havale dener:

static final String YATIR =
    "UPDATE hesaplar SET bakiye = bakiye + ? WHERE id = ?";
static final String CEK =
    "UPDATE hesaplar SET bakiye = bakiye - ? WHERE id = ?";

public static void main(String[] args) throws SQLException {
  String url = "jdbc:sqlite::memory:";  // bellekte, geçici
  try (Connection conn = DriverManager.getConnection(url);
       Statement st = conn.createStatement()) {
    st.execute("CREATE TABLE hesaplar (id INTEGER PRIMARY KEY, "
        + "sahip TEXT, bakiye INTEGER CHECK (bakiye >= 0))");
    st.execute("INSERT INTO hesaplar(sahip, bakiye) "
        + "VALUES ('Ali', 1000), ('Ece', 500)");
    havale(conn, 1, 2, 300);    // Ali → Ece 300 TL
    havale(conn, 1, 2, 5000);   // Ali'nin parası yetmez
    yazdir(conn);
  }
}

hesaplar tablosunun bakiye sütununda CHECK (bakiye >= 0) kısıtı vardır. Bakiyeyi eksiye düşüren her komut bir SQLException fırlatır. İkinci havalede Ali'nin 700 TL'si 5000 TL'lik havaleye yetmez. havale metodu şöyledir:

static void havale(Connection conn, int kimden, int kime,
                   int tutar) throws SQLException {
  conn.setAutoCommit(false);         // işlem başlar
  try (PreparedStatement p1 = conn.prepareStatement(YATIR);
       PreparedStatement p2 = conn.prepareStatement(CEK)) {
    p1.setInt(1, tutar);  p1.setInt(2, kime);
    p1.executeUpdate();              // alıcıya yatır
    p2.setInt(1, tutar);  p2.setInt(2, kimden);
    p2.executeUpdate();              // göndericiden çek
    conn.commit();                   // kalıcı yap
    System.out.println(tutar + " TL aktarıldı.");
  } catch (SQLException e) {
    conn.rollback();                 // geri al
    System.out.println("Geri alındı: " + tutar + " TL");
  } finally {
    conn.setAutoCommit(true);
  }
}

Metot önce otomatik onayı kapatır; işlem başlar. İki PreparedStatement ile önce alıcıya yatırır, sonra göndericiden çeker. İki komut da başarılıysa commit() ikisini birlikte kalıcı yapar. Herhangi bir komut SQLException fırlatırsa denetim catch'e geçer ve rollback() işlemdeki bütün değişiklikleri geri alır. finally bloğu, hangi yoldan gelinirse gelinsin, otomatik onayı yeniden açar; bağlantı sonraki kullanıcılar için varsayılan hâline döner. yazdir metodu, listele gibi bir SELECT döngüsüdür ve her hesabın sahibini ve bakiyesini yazar. Program çalıştırıldığında şu çıktıyı verir:

300 TL aktarıldı.
Geri alındı: 5000 TL
Ali: 700
Ece: 800

11.9.3Kod izleme: commit ve rollback

Tablo 11.23 iki havale boyunca bakiyelerin ve işlemin durumunu verir. “Geçici” sütunu commit edilmemiş değişiklikleri gösterir.

Tablo 11.23: Havale programının izlenmesi (satır numaraları havale metoduna göredir).
AdımSatırOlayAliEceDurumYeni çıktı
13setAutoCommit(false): birinci havale, işlem başlar1000500kalıcı—
26–7Ece'ye 300 yatırılır1000800geçici—
38–9Ali'den 300 çekilir; kısıt sağlanıyor700800geçici—
410–11commit(): iki değişiklik birlikte kalıcı700800kalıcı300 TL aktarıldı.
516finally: otomatik onay yeniden açılır700800kalıcı—
63, 6–7İkinci havale: Ece'ye 5000 yatırılır7005800geçici—
78–9700 − 5000 < 0: CHECK bozulur, SQLException7005800geçici—
812–14rollback(): geçici yatırma geri alınır700800kalıcıGeri alındı: 5000 TL
geçici (commit yok)idsahipbakiye1Ali7002Ece5800 rollback: geri alındıidsahipbakiye1Ali7002Ece800
Şekil 11.28: İkinci havalede geçici yatırmadan sonra (sol) ve rollback()'ten sonra (sağ).

Birinci havale sorunsuzdur: iki komut geçici olarak uygulanır, commit() ikisini birlikte kalıcı yapar. İkinci havalede yatırma komutu başarılı olur ve Ece'nin bakiyesi geçici olarak 5800'e çıkar (Şekil 11.28, sol). Çekme komutu Ali'nin bakiyesini −4300 yapacağı için CHECK kısıtına takılır ve SQLException fırlar. Ali'nin bakiyesi hiç değişmez: reddedilen komut uygulanmaz. commit() satırına gelinmez. catch bloğundaki rollback(), işlemde o ana kadar yapılmış olan yatırmayı da geri alır; Ece'nin bakiyesi 800'e döner. Para ne kaybolmuş ne çoğalmıştır: başta 1500 TL vardı, sonda da 700 + 800 = 1500 TL vardır.

Bu programın değeri, işlem olmadan ne olacağına bakınca anlaşılır. Aynı metottan setAutoCommit(false), commit() ve rollback() çıkarılıp program yeniden çalıştırıldı:

300 TL aktarıldı.
Geri alındı: 5000 TL
  ([SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (CHECK constraint failed: bakiye >= 0))
Ali: 700
Ece: 5800

İkinci havalede yatırma komutu çalışır çalışmaz kalıcı oldu. Çekme komutu reddedildi ama geri alınacak bir işlem yoktu. Program yine “Geri alındı” yazdı, ama yanlış: Ece'nin hesabında 5800 TL vardır ve 5000 TL yoktan var olmuştur. Toplam para 6500 TL'ye çıkmıştır.

Sık yapılan hata

catch bloğunda rollback()'i unutmak. Otomatik onay kapalıyken bir komut başarısız olur ve rollback() çağrılmazsa, işlemin başarılı komutları geçici hâlde bekler. Ardından finally'deki setAutoCommit(true) çalışır. JDBC kuralına göre işlem sürerken otomatik onayı açmak işlemi commit eder. Denemede Ece'ye yatırılan 5000 TL bu yolla kalıcı oldu: Ali 1000, Ece 5500. Hata yakalandı ama veri bozuldu. Hata yolunda rollback() her zaman çağrılır.

11.9.4Toplu işlem

Bir ölçüm cihazının 1000 değerini tabloya yazmak için 1000 INSERT gerekir. Bu komutlar tek tek gönderilebilir ya da bir kuyrukta biriktirilip birlikte gönderilebilir.

Tanım 11.14

Toplu işlem (batch update), birçok komutu addBatch() ile bir kuyrukta biriktirip executeBatch() ile birlikte göndermektir. executeBatch() her komutun etkilediği satır sayısını bir int[] dizisi olarak döndürür.

conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
        "INSERT INTO olcumler(deger) VALUES(?)")) {
  for (int i = 1; i <= 1000; i++) {
    ps.setInt(1, i);
    ps.addBatch();
  }
  int[] sonuc = ps.executeBatch();
  conn.commit();
  System.out.println(sonuc.length + " komut, ilki "
      + sonuc[0] + " satır etkiledi");
} catch (SQLException e) {
  conn.rollback();
}
conn.setAutoCommit(true);
1000 komut, ilki 1 satır etkiledi
1000 satır, toplam 500500

Döngünün her turunda yer tutucu doldurulur ve addBatch() doldurulmuş komutu kuyruğa ekler. Döngü bittiğinde kuyrukta 1000 komut vardır. executeBatch() hepsini gönderir ve 1000 öğeli bir dizi döndürür; her öğe 1'dir, çünkü her ekleme bir satır etkilemiştir. Çıktının ikinci satırı denetim için eklendi. Tabloda 1000 satır vardır; değerlerin toplamı 1 + 2 + … + 1000 = 1000 · 1001 / 2 = 500 500'dür.

Bu kodun hızı iki kaynaktan gelir: tek işlem ve toplu gönderim. Otomatik onay açıkken her INSERT ayrı bir işlemdir. SQLite her işlemin sonunda kalıcılığı sağlamak için veriyi diske yazdırır ve yazmanın bitmesini bekler. Disk üzerindeki bir .db dosyasıyla üç ölçüm yapıldı (Tablo 11.24).

Tablo 11.24: Disk üzerindeki SQLite dosyasına 1000 satır ekleme süreleri (üç çalıştırma, ms). Süreler makineye ve çalıştırmaya göre değişir.
Yöntem1. çalıştırma2. çalıştırma3. çalıştırma
Tek tek, otomatik onay açık184120471864
addBatch + tek işlem789
Tek tek executeUpdate + tek işlem222

Otomatik onay açıkken 1000 satır yaklaşık 2 saniye sürdü; tek işlemle aynı iş birkaç milisaniyede bitti. Fark yüzlerce kattır. Tablonun son satırı öğretici bir ayrıntı taşır: SQLite'ta asıl kazanç tek işlemden gelir. Komutlar tek tek gönderilse bile, tek işlem içinde olduklarında toplu gönderim kadar hızlıdır. Bunun nedeni SQLite'ın programın içinde çalışmasıdır; komut göndermek bir yöntem çağrısından ibarettir. MySQL gibi bir sunucuda ise her komut ağ üzerinden bir gidiş-dönüş ister ve addBatch bu gidiş-dönüşlerin sayısını da azaltır.

Bütün kuyruk tek işlem olduğu için biri başarısız olursa rollback() kuyruğun tamamının etkisini geri alır. Bunu görmek için tabloya CHECK (deger <> 500) kısıtı eklenip aynı kod çalıştırıldı:

SQLiteException: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (CHECK constraint failed: deger <> 500)
tabloda 0 satır

500. komut reddedildi, catch bloğu rollback() çağırdı ve tablo boş kaldı: ya hepsi ya hiçbiri. Standart, toplu işlemdeki hatanın SQLException'ın bir alt sınıfı olan BatchUpdateException ile bildirilmesini öngörür. SQLite sürücüsü burada kendi SQLiteException'ını fırlattı. İkisi de SQLException olduğu için catch (SQLException e) iki durumu da yakalar.

Çok büyük yüklemelerde kuyruk parça parça gönderilir. Örneğin her 500 addBatch'te bir executeBatch çağrılır. Böylece bir milyon komut bellekte birikmez ve bellek şişmez; işlem yine tek bir commit ile bitebilir.

11.10Örnek problemler ve alıştırmalar

Örnek 11.2

Boş bir veri tabanında aşağıdaki komutlar sırayla çalışıyor. Son SELECT hangi satırları döndürür?

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO users(name) VALUES ('Ali');
INSERT INTO users(name) VALUES ('Can');
DELETE FROM users WHERE id = 2;
INSERT INTO users(name) VALUES ('Ece');
UPDATE users SET name = 'Efe' WHERE id = 1;
SELECT * FROM users;

Ali 1, Can 2 numarasını alır. Can silinince tablodaki en büyük id 1 olur. Ece en büyük id + 1 = 2'yi alır: silinen numara yeniden kullanılmıştır. UPDATE yalnız 1 numaralı satırı değiştirir; Ali'nin adı Efe olur. Sonuç (1, Efe) ve (2, Ece)'dir. Komutlar JDBC ile çalıştırılarak doğrulandı. Tablo AUTOINCREMENT ile tanımlansaydı Ece 3 alırdı.

Örnek 11.3

Kişi programı kisiler.db ile ikinci kez çalıştırılıyor. Çıktının ilk bloğu (ilk ---'ye kadar) ve son bloğu nedir?

Dosya kalıcıdır. İlk çalıştırmadan Ayşe (id 2) kalmıştı ve CREATE TABLE IF NOT EXISTS tabloyu silmez. Yeni kayıtlar en büyük id + 1 kuralıyla 3 (Ahmet) ve 4 (Ayşe) numaralarını alır. guncelle Ahmet'i Akdeniz 35 yapar. İlk blok: 2 Ayşe Demir 40, 3 Ahmet Akdeniz 35, 4 Ayşe Demir 40. sil Ahmet'i siler. Son blok: 2 Ayşe Demir 40, 4 Ayşe Demir 40. Aynı kişi artık iki kez kayıtlıdır. Programın ikinci çalıştırması gerçekten şu çıktıyı verdi:

2 Ayşe Demir 40
3 Ahmet Akdeniz 35
4 Ayşe Demir 40
---
2 Ayşe Demir 40
4 Ayşe Demir 40
---

Bu sonuç bir tasarım eksiğini gösterir. Aynı kişinin iki kez eklenmesi istenmiyorsa tabloya UNIQUE(ad, soyad) gibi bir kısıt konur ya da program eklemeden önce kişinin var olup olmadığını sorgular.

Örnek 11.4

Bir giriş ekranı şu kodla denetleniyor. Tabloda üç kişi var. Kullanıcı ad olarak ahmet, şifre olarak ' OR '1'='1 yazarsa oluşan SQL nedir, kaç satır döner, giriş başarılı olur mu?

String sql = "SELECT * FROM kisiler WHERE ad = '" + ad
           + "' AND sifre = '" + sifre + "'";
ResultSet rs = stmt.executeQuery(sql);
boolean girisBasarili = rs.next();

Birleştirme şu metni üretir: SELECT * FROM kisiler WHERE ad = 'ahmet' AND sifre = '' OR '1'='1'. SQL'de AND, OR'dan önce uygulanır. Koşul (ad = 'ahmet' AND sifre = '') OR '1'='1' olarak okunur. Sağ taraf her satırda doğrudur; 3 satır döner. rs.next() true döndürür ve giriş şifresiz başarılı olur. Çözüm WHERE ad = ? AND sifre = ? ile PreparedStatement kullanmaktır; aynı girdiyle 0 satır döner. Üçü de çalıştırılarak doğrulandı:

SELECT * FROM kisiler WHERE ad = 'ahmet' AND sifre = '' OR '1'='1'
giriş: true, satır: 3
PreparedStatement giriş: false
doğru şifre: true
SELECT * FROM kisiler WHERE ad = 'ahmet' --' AND sifre = 'yanlis'
yorumla giriş: true

Çıktının son iki satırı başka bir saldırı biçimini gösterir. Ad alanına ahmet' -- yazılırsa -- SQL açıklaması başlatır ve şifre koşulu hiç okunmaz; yanlış şifreyle bile giriş başarılı olur. Gerçek sistemlerde şifreler zaten düz metin olarak değil, özetlenerek (hash) saklanır.

Örnek 11.5

havale metodundan setAutoCommit(false), commit() ve rollback() satırları çıkarılsaydı program sonunda Ali'nin ve Ece'nin bakiyesi ne olurdu?

Birinci havale aynı biçimde başarılı olur: Ali 700, Ece 800. İkinci havalede yatırma hemen kalıcı olur ve Ece 5800'e çıkar. Çekme CHECK yüzünden başarısız olur, geri alacak işlem yoktur. Sonuç: Ali 700, Ece 5800. 5000 TL yoktan var olmuştur ve program yine de “Geri alındı” yazar. Program bu biçimde çalıştırılarak doğrulandı.

Sunumdaki dört alıştırmanın çözümleri de JDK 21 ve SQLite sürücüsüyle denendi. Birinci alıştırma silme programını executeUpdate'in dönüş değerine göre ileti verecek biçimde düzeltir:

int sayi = stmt.executeUpdate(sql);
if (sayi > 0) System.out.println("Veri silindi!");
else System.out.println("Kayıt bulunamadı.");

Program aynı satırı iki kez silmeye çalıştığında önce “Veri silindi!”, sonra “Kayıt bulunamadı.” yazdı. İkinci alıştırmada ogrenciler(id, ad, vize, final) tablosuna Ada (60, 70), Bora (30, 50) ve Cem (80, 40) bir döngüde PreparedStatement ile eklenir. Ortalaması (vize yüzde 40, final yüzde 60) 50 ve üstü olanlar büyükten küçüğe listelenir:

SELECT ad, vize * 0.4 + final * 0.6 AS ort FROM ogrenciler
WHERE vize * 0.4 + final * 0.6 >= 50 ORDER BY ort DESC

Ada'nın ortalaması 60 · 0,4 + 70 · 0,6 = 24 + 42 = 66'dır. Bora'nınki 12 + 30 = 42, Cem'inki 32 + 24 = 56'dır. Bora elenir; çıktı Ada 66.0 ve Cem 56.0'dır. Değer getDouble("ort") ile okunur. WHERE'de ifade yeniden yazılmıştır, çünkü mantıksal sırada WHERE, SELECT'ten önce gelir ve ort takma adı henüz tanımlı değildir. ORDER BY ise takma adı kullanabilir. Katsayılar 0.4 gibi ondalıklı yazıldığı için sonuç REAL'dir. (vize * 4 + final * 6) / 10 gibi bir tam sayı ifadesi ise SQLite'ta tam sayı bölmesi yapar ve kesirli kısmı atar.

Üçüncü alıştırma menülü bir programdır: 1-Ekle, 2-Güncelle, 3-Sil, 4-Listele. Menü Scanner ve switch ile kurulur. nextInt() satır sonunu tamponda bıraktığı için ardından bir nextLine() çağrılır; yoksa sonraki ad okuması boş dizgi döner. Güncelleme UPDATE … WHERE id = ?, silme DELETE … WHERE id = ? ile yapılır. executeUpdate'in döndürdüğü sayı 0 ise o id'li kullanıcı yoktur ve “Kullanıcı bulunamadı.” yazılır. Dördüncü alıştırma oyun skorlarını skorlar(oyuncu, puan) tablosuna INSERT INTO skorlar(oyuncu, puan) VALUES(?, ?) ile kaydeder. En yüksek 10 skor SELECT oyuncu, puan FROM skorlar ORDER BY puan DESC LIMIT ? ve ps.setInt(1, 10) ile listelenir. LIMIT'in değeri de bir yer tutucuyla verilebilir; 12 skorlu bir tabloda sorgu büyükten küçüğe 10 satır döndürdü.

11.11Sık yapılan hatalar

Bölüm boyunca anılan hatalar burada gerçek iletileriyle toplanır. Tablo 11.25 JDBC ve temel SQL hatalarını, Tablo 11.26 ilişki, gruplama ve işlem hatalarını verir. İletiler SQLite JDBC 3.40.0.0'ın getMessage() çıktısından alınmıştır; sürücü her iletinin başına [SQLITE_ERROR] SQL error or missing database gibi bir hata kodu ekler, tabloda yalnız parantez içindeki asıl ileti verilir.

Tablo 11.25: JDBC ve temel SQL'de sık yapılan hatalar.
HataÖrnek ve ileti
Sürücü sınıf yolunda değilJar eklenmemiş: No suitable driver found for jdbc:sqlite:sample.db
SQLException'ı ele almamaktry ya da throws yok (derleme hatası): unreported exception SQLException; must be caught or declared to be thrown
Tablo yok ya da adı yanlışTablo oluşturulmadan sorgu: no such table: users
Metni tırnaksız yazmakVALUES(Ahmet, …): no such column: Ahmet
Kesme işaretli metni birleştirmekVALUES('Ali'nin'): near "nin": syntax error
Yanlış execute metoduexecuteQuery ile INSERT: query does not return ResultSet (satır yine de eklenir)
Sütun sırasını 0'dan başlatmakrs.getString(0): column 0 out of bounds [1,3]
WHERE'siz UPDATE/DELETEHata vermez; bütün satırları değiştirir ya da siler (üç satırlı tabloda 3 döner)
Tablo 11.26: İlişkiler, gruplama ve işlemlerde sık yapılan hatalar.
HataÖrnek ve sonuç
= NULL ile karşılaştırmakWHERE bolum_id = NULL hata vermez, 0 satır döner. Doğrusu IS NULL.
ON'suz birleştirmeFROM ogrenciler, bolumler: her satır her satırla eşleşir, 4 × 3 = 12 satır.
Belirsiz sütun adıİki tabloda da ad var, tablo adı yazılmamış: ambiguous column name: ad
WHERE'de toplama fonksiyonuGrup koşulu HAVING'e yazılır: misuse of aggregate: SUM()
Yabancı anahtar denetimini açmamakSQLite'ta PRAGMA foreign_keys = ON olmadan bolum_id = 9 sessizce kabul edilir.
wasNull()'u geç çağırmakSon okunan sütuna bakar; ilgili getInt'in hemen ardından çağrılır.
Gruplanmayan sütunu seçmekSELECT musteri, tutar … GROUP BY musteri: SQLite gruptaki satırlardan birinin tutarını getirir; birçok veri tabanında hatadır.
Otomatik onay açıkken binlerce INSERTHer satır ayrı işlem: çok yavaş. setAutoCommit(false), addBatch, commit.
Hata yolunda rollback()'i unutmakfinally'deki setAutoCommit(true) yarım işlemi commit eder.

Birinci tablonun çalışma zamanı satırları tek bir programda yeniden üretildi:

tablo yok -> [SQLITE_ERROR] SQL error or missing database (no such table: users)
tırnaksız -> [SQLITE_ERROR] SQL error or missing database (no such column: Ahmet)
kesme işareti -> [SQLITE_ERROR] SQL error or missing database (near "nin": syntax error)
yanlış metot -> query does not return ResultSet
indis 0 -> column 0 out of bounds [1,3]
indis 4 -> column 4 out of bounds [1,3]
WHERE'siz UPDATE -> 3
WHERE'siz DELETE -> 3
kalan 0
tablo duruyor mu -> 1
sınıf: org.sqlite.SQLiteException, kod: 1, SQLState: null

Son satır, yakalanan istisnanın kendisini inceler. Sınıfı org.sqlite.SQLiteException'dır; bu sınıf SQLException'ın bir alt sınıfıdır. getErrorCode() SQLite'ın hata kodunu (1, genel SQL hatası) verir. Hataların çoğunda program derlenir ve çalışır; sorun ancak çalışma zamanında, çoğu zaman da sessizce ortaya çıkar. Bu yüzden JDBC kodu yazıldıktan sonra tablonun gerçek içeriği, DB Browser for SQLite ya da bir SELECT ile mutlaka denetlenir.

11.12Özet

  • Veri tabanı, kalıcı, düzenli ve sorgulanabilir veri topluluğudur; onu VTYS yönetir. Dosyaya göre hızlı arama, güvenli eş zamanlı erişim, kısıtlarla tutarlılık, alan düzeyinde yetki ve hatadan sonra kurtarma sağlar.
  • İlişkisel model veriyi tablolarda tutar: sütun bir özellik, satır bir kayıttır; birincil anahtar her satırı tekil olarak tanımlar. Tablo bir sınıfa, sütun bir alana, satır bir nesneye benzer.
  • SQLite gömülü, sunucusuz ve tek dosyalı bir ilişkisel veri tabanıdır. Java'dan kullanmak için sürücü jar'ı sınıf yolunda olmalıdır; adres jdbc:sqlite:sample.db biçimindedir.
  • Temel SQL komutları CRUD işlemlerini karşılar: INSERT, SELECT, UPDATE, DELETE. WHERE satırları süzer, ORDER BY sıralar, LIMIT sınırlar. WHERE'siz UPDATE ve DELETE bütün satırları etkiler.
  • INTEGER PRIMARY KEY'e değer verilmezse SQLite en büyük id + 1 atar; AUTOINCREMENT kullanılmış bir numarayı yeniden vermez.
  • Normalleştirme her bilgiyi tek yerde tutar ve güncelleme, ekleme, silme anomalilerini önler. Tablolar yabancı anahtarla bağlanır; SQLite'ta denetim PRAGMA foreign_keys = ON ile açılır.
  • JOIN … ON yalnız eşleşen satırları, LEFT JOIN soldaki tablonun bütün satırlarını verir. ON unutulursa Kartezyen çarpım oluşur.
  • GROUP BY satırları gruplar; WHERE gruplamadan önce satırları, HAVING gruplamadan sonra grupları süzer. Mantıksal sıra: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.
  • NULL bilinmeyen değerdir; IS NULL ile denetlenir. getInt NULL için 0 döndürür; ayırt etmek için hemen ardından wasNull() çağrılır.
  • İndeks aramayı tarama yerine B-ağacı aramasına çevirir; bedeli her yazmada indeksin de güncellenmesidir.
  • JDBC zinciri: DriverManager → Connection → Statement/PreparedStatement → ResultSet. Bunlar arayüzdür; sürücü onları uygular.
  • execute tablo işlemleri için boolean, executeUpdate etkilenen satır sayısını, executeQuery bir ResultSet döndürür. Sonuç while (rs.next()) ile okunur; sütun numaraları 1'den başlar.
  • Kullanıcı girdisi SQL metnine eklenmez; her zaman ? ve setXxx ile verilir. Aksi hâlde SQL enjeksiyonu oluşur.
  • DAO bir tablonun SQL'ini tek sınıfta toplar ve satırları nesne olarak döndürür.
  • İşlem: setAutoCommit(false), commit(), hata durumunda rollback(); ya hepsi ya hiçbiri (ACID). Toplu eklemeler tek işlemde yapılır.
  • Kaynaklar try-with-resources ile açılır ve rs, stmt, conn sırasıyla kapanır. SQLException kontrol edilen istisnadır.

11.13Kavram listesi

11.14Kendini Dene

Sorular bölümün üç tablosunu kullanır: ogrenciler (Ada 1, Bora 2, Cem 1, Deniz NULL), bolumler (Bilgisayar, Elektrik, Makine) ve siparisler (Ali 120, Ece 80, Ali 50, Can 200, Ali 30, Ece 40). Cevaplar JDK 21 ve SQLite JDBC ile çalıştırılarak doğrulandı.

  1. SELECT COUNT(*) FROM ogrenciler WHERE bolum_id <> 2 kaç döndürür?

    Cevap

    2. Ada ve Cem (1) koşulu sağlar; Bora (2) sağlamaz. Deniz'in bolum_id'si NULL'dur ve NULL <> 2 doğru değil, bilinmiyordur; Deniz sayılmaz.

  2. id INTEGER PRIMARY KEY sütunlu bir tabloya üç satır eklenir (1, 2, 3), 2 numaralı satır silinir ve yeni bir satır eklenir. Tablodaki numaralar nedir?

    Cevap

    1, 3, 4. Yeni satır en büyük id + 1 = 4 alır. Ortadaki boşluk doldurulmaz; yalnız en büyük numara silinseydi o numara yeniden kullanılırdı.

  3. SELECT musteri, SUM(tutar) FROM siparisler WHERE tutar < 100 GROUP BY musteri HAVING COUNT(*) >= 2 ne döndürür?

    Cevap

    WHERE önce 120 ve 200'lük siparişleri eler. Ali'nin 50 ve 30'u, Ece'nin 80 ve 40'ı, Can'ın hiç siparişi kalmaz. İki grubun da adedi 2'dir. Sonuç: Ali 80 ve Ece 120.

  4. SELECT COUNT(*) FROM bolumler b LEFT JOIN ogrenciler o ON o.bolum_id = b.id kaç döndürür? LEFT kaldırılırsa?

    Cevap

    Bilgisayar iki, Elektrik bir öğrenciyle eşleşir; Makine eşleşmez ama LEFT JOIN onu bir satırla korur: 4. Düz JOIN'de Makine düşer: 3. Deniz iki sorguda da yoktur, çünkü korunan taraf bölümlerdir.

  5. Ad araması "SELECT ad FROM kisiler WHERE ad = '" + girdi + "'" ile yapılıyor. Kullanıcı ' OR 1=1 -- yazarsa üç kişilik tabloda kaç satır döner?

    Cevap

    SQL metni SELECT ad FROM kisiler WHERE ad = '' OR 1=1 --' olur. -- sondaki tırnağı açıklamaya çevirir; koşul her satırda doğrudur. 3 satır döner. PreparedStatement ile 0 satır dönerdi.

  6. Malzeme tablosunda zımba 40, makas 10, bant 25 var. Bir Statement'a sırayla INSERT … ('kalem', 5), UPDATE malzeme SET stok = stok + 10 WHERE stok < 20 ve DELETE FROM malzeme WHERE stok > 100 komutları addBatch ile eklenip executeBatch() çağrılıyor. Dönen dizi nedir?

    Cevap

    [1, 2, 0]. Ekleme bir satır etkiler. Güncellemede stoğu 20'den az olanlar makas (10) ve yeni eklenen kalemdir (5); ikisi 20 ve 15 olur. Silinecek satır yoktur. Statement'ın addBatch(String) biçimi farklı SQL metinlerini de kuyruğa alır.

  7. ogrenciler tablosu id sırasıyla okunuyor; her satırda toplam += rs.getInt("bolum_id") yapılıyor ve rs.wasNull() doğruysa bos++. Sonunda toplam ve bos kaçtır?

    Cevap

    getInt Deniz için 0 verir: 1 + 2 + 1 + 0 = 4. wasNull() yalnız Deniz'de doğrudur: bos = 1.

  8. uye(id INTEGER PRIMARY KEY, email TEXT UNIQUE) boş. setAutoCommit(false), a@x ve b@x eklenir, rollback(), c@x eklenir, commit(). Tabloda hangi satırlar vardır?

    Cevap

    Tek satır: (1, c@x). rollback() ilk iki eklemeyi geri alır; geri alınan eklemeler numara da tüketmez. c@x boş tabloya eklenir ve 1 alır.

11.15İleri okuma

  • Oracle, The Java Tutorials, “JDBC Database Access”: bağlantı kurmayı, sorgu çalıştırmayı, PreparedStatement'ı ve işlemleri kısa örneklerle anlatan resmî ve ücretsiz kaynaktır.
  • SQLite belgeleri, “Datatypes In SQLite” ve “Appropriate Uses For SQLite”: SQLite'ın esnek tip sistemini anlatır; sunucu tabanlı bir veri tabanının hangi durumlarda daha uygun olduğunu da açıklar.
  • OWASP, SQL Injection Prevention Cheat Sheet: enjeksiyonun türlerini ve önceden hazırlanan deyimlerin neden birincil savunma olduğunu örneklerle anlatır.
  • C. S. Horstmann, Core Java, Volume II: Advanced Features (12. baskı), “Database Programming” bölümü: JDBC'yi, işlemleri ve sonuç kümelerinin ileri özelliklerini ayrıntılı işler.

11.16Kaynaklar

  1. Oracle, The Java Tutorials: JDBC Database Access (Establishing a Connection; Processing SQL Statements with JDBC; Using Prepared Statements; Using Transactions).
  2. Oracle, Java SE 21 API Documentation: java.sql paketi (DriverManager, Connection, PreparedStatement, ResultSet).
  3. SQLite belgeleri, sqlite.org: SQL As Understood By SQLite; Datatypes In SQLite; Appropriate Uses For SQLite.
  4. Xerial, SQLite JDBC Driver, github.com/xerial/sqlite-jdbc.
  5. P. Deitel ve H. Deitel, Java: How to Program, 11. baskı, Pearson (Accessing Databases with JDBC).
  6. C. S. Horstmann, Core Java, Volume II: Advanced Features, 12. baskı, Pearson (Database Programming).
  7. OWASP, SQL Injection Prevention Cheat Sheet.

İlgili sayfalar: Bölüm 11 sunumu · Çalışma soruları · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.