Mert Özen Her Satırda Daha İleri
Veritabanı Dapper — #03

Dapper ile Tek Satır Sorguları: QueryFirst, QuerySingle ve ExecuteScalar

Mert Özen 6 Kas 2026 10 dk 3 görüntülenme
Dapper ile Tek Satır Sorguları: QueryFirst, QuerySingle ve ExecuteScalar

QueryFirst, QueryFirstOrDefault, QuerySingle ve QuerySingleOrDefault'u sıfır, bir ve çok satırla yan yana çalıştırıp hangi exception'ı ne zaman fırlattıklarını görüyoruz; default(T), nullable tipler ve ExecuteScalar<T> tuzaklarıyla.

Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.

Önce Tahmin Et

Aynı SQL'i üç farklı parameter ile, iki farklı method üzerinden çalıştırıyoruz. Ursula K. Le Guin'in (AuthorId = 1) veritabanında üç kitabı var, 99 numaralı bir yazar ise hiç yok:

const string sql = "SELECT id, title, price FROM books WHERE author_id = @AuthorId";

var a = connection.QueryFirst<Book>(sql, new { AuthorId = 1 });
Console.WriteLine($"a: {a.Title}");

var b = connection.QuerySingleOrDefault<Book>(sql, new { AuthorId = 99 });
Console.WriteLine($"b: {b?.Title ?? "null"}");

var c = connection.QuerySingleOrDefault<Book>(sql, new { AuthorId = 1 });
Console.WriteLine($"c: {c?.Title ?? "null"}");

Üç satırdan hangisi exception fırlatır? Çoğu kişinin aklına önce b gelir, çünkü orada hiç satır yok. Bir kısmı da c için "OrDefault yazıyor, en kötü ihtimalle null döner" der. Kendi tahminini yap; cevap "QuerySingle: Bir Sözleşme" bölümünde.

Bookstore Veritabanı

İlk yazıdaki kurulumu kullanıyoruz: .NET 10 console projesi, Dapper 2.1.89, Microsoft.Data.Sqlite 10.0.14 ve her çalıştırmada veritabanını baştan kuran BookstoreDb.cs:

using Dapper;
using Microsoft.Data.Sqlite;

public static class BookstoreDb
{
    public const string ConnectionString = "Data Source=bookstore.db";

    public static void Reset()
    {
        using var connection = new SqliteConnection(ConnectionString);
        connection.Execute("""
            DROP TABLE IF EXISTS order_items;
            DROP TABLE IF EXISTS orders;
            DROP TABLE IF EXISTS books;
            DROP TABLE IF EXISTS authors;

            CREATE TABLE authors (
                id      INTEGER PRIMARY KEY,
                name    TEXT NOT NULL,
                country TEXT
            );
            CREATE TABLE books (
                id             INTEGER PRIMARY KEY,
                author_id      INTEGER NOT NULL REFERENCES authors(id),
                title          TEXT NOT NULL,
                price          REAL NOT NULL,
                published_year INTEGER,
                stock          INTEGER NOT NULL DEFAULT 0
            );
            CREATE TABLE orders (
                id             INTEGER PRIMARY KEY,
                customer_email TEXT NOT NULL,
                created_at     TEXT NOT NULL
            );
            CREATE TABLE order_items (
                id         INTEGER PRIMARY KEY,
                order_id   INTEGER NOT NULL REFERENCES orders(id),
                book_id    INTEGER NOT NULL REFERENCES books(id),
                quantity   INTEGER NOT NULL,
                unit_price REAL NOT NULL
            );

            INSERT INTO authors (id, name, country) VALUES
                (1, 'Ursula K. Le Guin', 'USA'),
                (2, 'Frank Herbert', 'USA'),
                (3, 'Stanislaw Lem', 'Poland'),
                (4, 'Douglas Adams', 'UK'),
                (5, 'Ted Chiang', 'USA');

            INSERT INTO books (id, author_id, title, price, published_year, stock) VALUES
                (1, 1, 'A Wizard of Earthsea', 12.50, 1968, 8),
                (2, 1, 'The Left Hand of Darkness', 14.00, 1969, 3),
                (3, 1, 'The Dispossessed', 15.25, 1974, 0),
                (4, 2, 'Dune', 18.90, 1965, 12),
                (5, 3, 'Solaris', 11.40, 1961, 5),
                (6, 4, 'The Hitchhiker''s Guide to the Galaxy', 9.99, 1979, 20),
                (7, 5, 'Exhalation', 13.20, NULL, 4);
            """);
    }
}

Sonuçları taşıyan class ilk yazıdaki gibi Id, Title ve Price property'lerine sahip Book. Program.cs BookstoreDb.Reset(); ile başlıyor ve tek bir SqliteConnection açıyor.

Dört Method, Üç Senaryo

Tek satır döndüren method'lar iki soruya verdiğin cevaba göre ayrılır. Sıfır satır gelirse ne olsun: exception mı, varsayılan değer mi? Birden fazla satır gelirse ne olsun: ilkini alayım mı, yoksa bunu bir hata mı sayayım? First/Single ikinci soruya, OrDefault eki de birinci soruya verilen cevaptır. Dördünü üç senaryoda yan yana çalıştıralım: yazar 99 (sıfır kitap), Ted Chiang (tek kitap) ve Le Guin (üç kitap).

string Try(Func<Book?> call)
{
    try
    {
        return call()?.Title ?? "null";
    }
    catch (InvalidOperationException ex)
    {
        return $"{ex.GetType().Name}: {ex.Message}";
    }
}

foreach (var (label, authorId) in new[] { ("0 rows", 99), ("1 row", 5), ("3 rows", 1) })
{
    var p = new { AuthorId = authorId };
    Console.WriteLine($"--- {label}");
    Console.WriteLine($"QueryFirst            {Try(() => connection.QueryFirst<Book>(sql, p))}");
    Console.WriteLine($"QueryFirstOrDefault   {Try(() => connection.QueryFirstOrDefault<Book>(sql, p))}");
    Console.WriteLine($"QuerySingle           {Try(() => connection.QuerySingle<Book>(sql, p))}");
    Console.WriteLine($"QuerySingleOrDefault  {Try(() => connection.QuerySingleOrDefault<Book>(sql, p))}");
}
--- 0 rows
QueryFirst            InvalidOperationException: Sequence contains no elements
QueryFirstOrDefault   null
QuerySingle           InvalidOperationException: Sequence contains no elements
QuerySingleOrDefault  null
--- 1 row
QueryFirst            Exhalation
QueryFirstOrDefault   Exhalation
QuerySingle           Exhalation
QuerySingleOrDefault  Exhalation
--- 3 rows
QueryFirst            A Wizard of Earthsea
QueryFirstOrDefault   A Wizard of Earthsea
QuerySingle           InvalidOperationException: Sequence contains more than one element
QuerySingleOrDefault  InvalidOperationException: Sequence contains more than one element

Bu çıktıyı bir kere okuyup aklına kaydet, çünkü bütün yazı onun üzerine kurulu. Tek satır geldiğinde dördü de aynı davranıyor; fark kenar durumlarda. Exception mesajları da LINQ'ten tanıdık: Dapper bu method'larda First(), Single() gibi davranıyor ve aynı tipte, aynı mesajlı InvalidOperationException'ı fırlatıyor. 2.1.66 sürümü bu aileye doğruluk düzeltmeleri getirdi; çok eski bir sürümdeysen yükseltmek için bir sebep daha.

QueryFirst: İlk Satırı Kim Belirler?

Üç kitaplı senaryoda QueryFirst bize "A Wizard of Earthsea"yı verdi. Neden o? Çünkü SQL'de ORDER BY yok ve SQLite bu sefer satırları id sırasıyla döndürdü. "Bu sefer" kısmı önemli: sıralama belirtmediğin bir sorguda veritabanı satırları istediği sırayla döndürebilir. İndeks eklendiğinde, veri büyüdüğünde ya da başka bir veritabanına geçtiğinde "ilk" satır sessizce değişir.

QueryFirst'ün doğru kullanımı, hangi satırın ilk olduğunu SQL'de açıkça söylemektir:

var cheapest = connection.QueryFirst<Book>(
    "SELECT id, title, price FROM books ORDER BY price LIMIT 1");

Console.WriteLine($"{cheapest.Title} {cheapest.Price}");
The Hitchhiker's Guide to the Galaxy 9.99

LIMIT 1 burada sonucu değiştirmiyor ama işi doğru yere koyuyor. QueryFirst tek satıra ihtiyacın olduğunu C# tarafında söyler; veritabanı bunu senin SQL'inden öğrenir. Bin satırlık bir sonuçtan bir satır seçeceksen, bunu veritabanının yapması daha ucuz. SQL Server'da karşılığı TOP 1, PostgreSQL'de yine LIMIT 1.

QuerySingle: Bir Sözleşme

Tahmin sorusunun cevabı:

a: A Wizard of Earthsea
b: null
InvalidOperationException: Sequence contains more than one element

b sorunsuz null döndü; exception'ı fırlatan c oldu. QuerySingleOrDefault'taki OrDefault yalnızca "hiç satır yok" durumunu kapsar. Birden fazla satır gelmesi ise Single için her zaman bir sözleşme ihlalidir ve OrDefault bunu affetmez.

Bunu bir kusur değil, özellik olarak gör. QuerySingle yazdığında koda "bu sorgu tam olarak bir satır döndürmeli" diye bir iddia yerleştiriyorsun. Primary key ya da unique bir kolonla arama yaptığında bu iddia doğaldır:

var book = connection.QuerySingle<Book>(
    "SELECT id, title, price FROM books WHERE id = @Id",
    new { Id = 4 });

Console.WriteLine(book.Title);
Dune

Bir gün biri unique index'i kaldırır ya da bir migration aynı e-postayla iki müşteri kaydı oluşturursa, QuerySingle bunu ilk çalıştığı anda yüksek sesle söyler. Aynı yerde QueryFirst kullansaydın, iki kayıttan hangisi önce gelirse onu alıp yoluna devam ederdin ve veri hatası haftalarca fark edilmezdi. Bir benzetmeyle: QueryFirst kuyruktaki ilk kişiye bakar, QuerySingle ise kimlik kontrolü yapar ve aynı isimde iki kişi görürse kapıyı kapatır.

Bunun bir bedeli var: Dapper'ın birden fazla satır olup olmadığını anlayabilmesi için ikinci satıra bakması gerekir. Primary key aramasında bu bedel sıfırdır. Ama milyonlarca satır dönebilecek bir sorguda "nasılsa tek satır gelir" diye QuerySingle kullanma; o zaman sorun, method seçiminden önce SQL'de.

OrDefault ve default(T)

OrDefault method'ları sıfır satırda default(T) döndürür. Class'lar için bu null. Dapper 2.1'den beri API nullable reference type annotation'ları taşıdığı için QueryFirstOrDefault<Book> dönüş tipini Book? olarak bildirir ve derleyici seni uyarır:

var maybeBook = connection.QueryFirstOrDefault<Book>(
    "SELECT id, title, price FROM books WHERE id = @Id",
    new { Id = 42 });

Console.WriteLine(maybeBook.Title.Length);
Program.cs(10,19): warning CS8602: Dereference of a possibly null reference.

Bu uyarıyı ciddiye al; çalışma anında karşılığı NullReferenceException. Doğrusu sonucu nullable bir değişkene alıp "bulunamadı" durumunu açıkça ele almak:

Book? missing = connection.QueryFirstOrDefault<Book>(
    "SELECT id, title, price FROM books WHERE id = @Id",
    new { Id = 42 });

Console.WriteLine(missing?.Title ?? "not found");
not found

Value type'larda iş daha sinsi. int için default değer 0 ve 0 çoğu zaman geçerli bir veri:

int stock = connection.QueryFirstOrDefault<int>(
    "SELECT stock FROM books WHERE id = @Id", new { Id = 3 });
int noRow = connection.QueryFirstOrDefault<int>(
    "SELECT stock FROM books WHERE id = @Id", new { Id = 42 });
int? maybe = connection.QueryFirstOrDefault<int?>(
    "SELECT stock FROM books WHERE id = @Id", new { Id = 42 });

Console.WriteLine($"{stock} | {noRow} | {maybe?.ToString() ?? "null"}");
0 | 0 | null

İlk iki değer aynı: The Dispossessed'in stoğu gerçekten 0, 42 numaralı kitap ise hiç yok. int ile bu iki durumu ayırt edemezsin. Hedef tipi int? yaptığında "satır yok" null, "satır var, değeri 0" ise 0 olarak gelir.

Aynı konunun ters yüzü de var: satır var ama kolon NULL. Exhalation'ın yayın yılını QuerySingle<int> ile okumaya çalışalım:

try
{
    int year = connection.QuerySingle<int>(
        "SELECT published_year FROM books WHERE id = @Id", new { Id = 7 });
}
catch (DataException ex)
{
    Console.WriteLine(ex.Message);
}

int? knownYear = connection.QuerySingle<int?>(
    "SELECT published_year FROM books WHERE id = @Id", new { Id = 7 });

Console.WriteLine(knownYear?.ToString() ?? "unknown");
Error parsing column 0 (published_year=n/a - Null object cannot be converted to a value type.)
unknown

Bir önceki yazıda NULL bir kolon class'taki int property'yi sessizce 0'da bırakıyordu. Tek kolonlu bir sorguda hedef tipin kendisi int olduğunda ise Dapper'ın yazacak bir property'si yok ve System.Data.DataException fırlatıyor. Mesaj, sorunlu kolonun sırasını ve adını veriyor. Çözüm yine aynı: NULL olabilen her şey için nullable tip.

ExecuteScalar ile Tek Değer

Bazen satıra değil, tek bir değere ihtiyacın vardır: bir sayım, bir toplam, bir maksimum. ExecuteScalar<T> ilk satırın ilk kolonunu döndürür:

int bookCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM books");
object? raw = connection.ExecuteScalar("SELECT COUNT(*) FROM books");

double? noTotal = connection.ExecuteScalar<double?>(
    "SELECT SUM(price) FROM books WHERE author_id = @AuthorId", new { AuthorId = 42 });
double zeroTotal = connection.ExecuteScalar<double>(
    "SELECT SUM(price) FROM books WHERE author_id = @AuthorId", new { AuthorId = 42 });

Console.WriteLine($"{bookCount} | {raw?.GetType().Name} | {noTotal?.ToString() ?? "null"} | {zeroTotal}");

string? topTitle = connection.ExecuteScalar<string>(
    "SELECT title, price FROM books ORDER BY price DESC");

Console.WriteLine(topTitle);
7 | Int64 | null | 0
Dune

Generic olmayan ExecuteScalar, provider'ın döndürdüğü ham değeri verir; SQLite'ta COUNT(*) bir Int64. ExecuteScalar<int> bunu senin için int'e çevirir. Eşleşecek kolon adı olmadığı için tip dönüşümü burada serbest.

Ortadaki iki değere dikkat et. SQL'de eşleşen satır yokken SUM NULL döndürür. double? bunu dürüstçe null olarak gösterdi, double ise 0'a çevirdi. QuerySingle<int> NULL'da exception fırlatırken ExecuteScalar<double>'ın sessizce 0 döndürmesi tutarsız görünebilir, ama ikisinin de gerçek davranışı bu; hangisini kullandığını bilerek seç. "Toplam yok" ile "toplam sıfır" farklı şeylerse nullable tip kullan, değilse SQL'de COALESCE(SUM(price), 0) yaz ve niyetini görünür kıl.

Son satır da ExecuteScalar'ın neyi yok saydığını gösteriyor: iki kolon seçtik, birden fazla satır geldi, ama sadece ilk satırın ilk kolonu döndü. ExecuteScalar ne fazla satır ne de fazla kolon için uyarı verir.

Sık Düşülen Tuzaklar

Birincisi, ORDER BY'sız QueryFirst. Testte doğru çalışır, çünkü küçük tablolarda satırlar çoğu zaman eklenme sırasıyla gelir. Canlı ortamda ise sıralama garantisi yoktur. QueryFirst gördüğün her yerde bir ORDER BY ara.

İkincisi, her şeyi QueryFirstOrDefault ile okumak. "Exception fırlatmasın" diye seçilen bu method, birden fazla satır geldiğinde de sessiz kalır ve veri hatalarını gizler. Primary key ya da unique alan üzerinden arıyorsan QuerySingle ya da QuerySingleOrDefault kullan; bırak sözleşmeyi Dapper denetlesin.

Üçüncüsü, value type'larla OrDefault. QueryFirstOrDefault<int> ve ExecuteScalar<decimal> "yok" ile "sıfır"ı aynı değere indirger. Bu ayrım önemliyse nullable tip kullan.

Dördüncüsü, QuerySingle'ın exception'ını kullanıcıya göstermek. Sequence contains no elements bir API cevabında kimseye bir şey anlatmaz. Kaydın bulunmaması beklenen bir durumsa OrDefault kullanıp 404 gibi anlamlı bir cevaba çevir; QuerySingle'ın exception'ını gerçekten beklenmeyen durumlar için sakla.

Küçük Bir Deneme

★☆☆ ExecuteScalar<int> ile stoğu sıfır olan kitapların sayısını, ExecuteScalar<double> ile de bütün kitapların ortalama fiyatını (AVG) bul.

★★☆ authors tablosunda country kolonuna göre arama yapan bir sorgu yaz ve 'USA', 'Poland', 'Japan' için dört method'u ayrı ayrı dene. Hangi ülke hangi method'da exception üretiyor? Tahmin edip sonra çalıştır.

★★★ "Önce Tahmin Et"teki sorguya ORDER BY price DESC ekle ve a'nın değiştiğini gör. Sonra c'deki exception'ı, sorguyu bozmadan, LIMIT ile susturmayı dene. Bu "düzeltme" gerçekten bir düzeltme mi, yoksa sözleşmeyi mi devre dışı bıraktın?

Bu yazıdaki bütün sorgularda değerleri new { Id = 4 } gibi anonymous object'lerle verdik ve neden böyle yaptığımızı hiç sorgulamadık. Bir sonraki yazıda tam olarak bunu konuşacağız: parameter'lar, SQL'i string birleştirerek kurmanın neden yasak olduğu, DynamicParameters, IN @ids ve DbString. Seri burada bitmiyor; Dapper'ın yeni sürümleri çıktıkça yeni yazılarla devam edeceğiz.