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

Dapper ile Query ve Mapping: Kolonlar Property'lere Nasıl Eşlenir?

Mert Özen 6 Kas 2026 10 dk 3 görüntülenme
Dapper ile Query ve Mapping: Kolonlar Property'lere Nasıl Eşlenir?

Query<T>'nin kolonları property'lere nasıl eşlediğini görüyoruz: büyük-küçük harf duyarsız eşleme, sessizce atlanan kolonlar, NULL ve varsayılan değerler, MatchNamesWithUnderscores ve önbellek tuzağı, dynamic sonuçlar, record'larda constructor eşlemesi.

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

Önce Tahmin Et

Elimizde dört property'li bir Book class'ı var:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int PublishedYear { get; set; }
}

Dune'u, yayın yılıyla birlikte okuyoruz. books tablosunda Dune'un published_year değeri 1965:

var dune = connection.QuerySingle<Book>(
    "SELECT id, title, price, published_year FROM books WHERE id = 4");

Console.WriteLine($"{dune.Title} ({dune.PublishedYear})");

Ekranda ne görürsün? Dune (1965) mi, eşleşmeyen kolon yüzünden bir exception mı, yoksa başka bir şey mi? QuerySingle'ı şimdilik "tek satır döndüren Query" diye düşün, ayrıntısı bir sonraki yazıda. Cevabı birkaç bölüm sonra veriyoruz.

Bookstore Veritabanı

Bu yazıda da ilk yazıdaki kitapçı veritabanını kullanıyoruz: .NET 10 console projesi, Dapper 2.1.89 ve Microsoft.Data.Sqlite 10.0.14. Projeye eklediğin BookstoreDb.cs her çalıştırmada tabloları silip aynı örnek veriyle baştan kuruyor:

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);
            """);
    }
}

Program.cs'in başında BookstoreDb.Reset(); çağırıp using var connection = new SqliteConnection(BookstoreDb.ConnectionString); ile bir connection oluşturuyoruz; aşağıdaki parçaların hepsi bu connection'ı kullanıyor. Exhalation'ın yayın yılının NULL olduğuna dikkat et, ona da döneceğiz.

Mapping Nasıl Çalışır?

Dapper bir sorguyu çalıştırdığında reader'ın kolon adlarını sırayla alır ve her kolon için hedef tipte aynı ada sahip, yazılabilir bir property (ya da field) arar. Karşılaştırma büyük-küçük harfe duyarsız. Bunu en kaba hâliyle deneyelim, kolon adlarını bağırarak yazalım:

var loud = connection.QuerySingle<Book>(
    "SELECT ID, TITLE, PRICE FROM books WHERE id = 4");

Console.WriteLine($"{loud.Id} | {loud.Title} | {loud.Price}");
4 | Dune | 18.9

ID rahatça Id'ye, TITLE da Title'a gitti. Burada sessizce bir tip dönüşümü de oldu. SQLite tamsayıları 64 bitlik long, ondalıklı sayıları double olarak döndürür; Dapper long değeri int property'ye, double değeri decimal property'ye çevirdi. Bu dönüşümler property mapping'inde serbest, ama birazdan göreceğin gibi constructor mapping'inde değil.

Bir de şu var: eşleme kolon adıyla yapılır, sırayla değil. SELECT'teki kolonların sırası, property'leri dolduran bir class için önemsiz. İlk yazının son alıştırmasında ADO.NET versiyonunun kolon sırası değişince yanlış veri ürettiğini, Dapper'ınsa etkilenmediğini görmüştün; sebebi bu.

Eşleşmeyen Kolonlar: Sessiz Kayıp

Baştaki sorunun cevabı:

Dune (0)

Exception yok, uyarı yok, sadece 0. Dapper published_year adında bir property aradı, bulamadı ve o kolonu atladı. PublishedYear property'si de hiç dokunulmadığı için int'in varsayılan değerinde kaldı. Bu bir hata değil, bilinçli bir tasarım: Dapper eşleşmeyen kolonları da, karşılığı olmayan property'leri de sorun saymaz. Esnek ama affetmez; bir kolon adını yanlış yazarsan bunu ancak ekranda sıfırlar görünce fark edersin.

En doğrudan çözüm SQL tarafında alias vermek:

var aliased = connection.QuerySingle<Book>(
    "SELECT id, title, price, published_year AS PublishedYear FROM books WHERE id = 4");

Console.WriteLine($"{aliased.Title} ({aliased.PublishedYear})");

var exhalation = connection.QuerySingle<Book>(
    "SELECT id, title, price, published_year AS PublishedYear FROM books WHERE id = 7");

Console.WriteLine($"{exhalation.Title} ({exhalation.PublishedYear})");
Dune (1965)
Exhalation (0)

Dune düzeldi, ama ikinci satıra bak. Exhalation'ın yılı veritabanında NULL; Dapper NULL gördüğü bir kolonu property'ye yazmaz, int de yine 0'da kalır. "Yılı bilinmiyor" ile "0 yılında yayımlandı" arasındaki fark böylece kayboldu. Kolon NULL olabiliyorsa property'nin tipi de bunu söylemeli: int?.

snake_case ve MatchNamesWithUnderscores

Her sorguda alias yazmak, kolon sayısı arttıkça yorucu olur. Veritabanın baştan sona snake_case ise Dapper'a alt çizgileri yok saymasını söyleyebilirsin. Bu kez NULL ihtimalini de doğru modelleyen bir class kullanalım:

public class BookDetails
{
    public int Id { get; set; }
    public int AuthorId { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public int? PublishedYear { get; set; }
    public int Stock { get; set; }
}
DefaultTypeMap.MatchNamesWithUnderscores = true;

var details = connection.Query<BookDetails>(
    "SELECT id, author_id, title, price, published_year, stock FROM books WHERE id IN (4, 7)");

foreach (var d in details)
{
    Console.WriteLine($"{d.Title}: author {d.AuthorId}, year {d.PublishedYear?.ToString() ?? "unknown"}, stock {d.Stock}");
}
Dune: author 2, year 1965, stock 12
Exhalation: author 5, year unknown, stock 4

author_id artık AuthorId'ye, published_year da PublishedYear'a gidiyor ve Exhalation'ın yılı dürüstçe "unknown". Bu ayar static ve bütün uygulamayı etkiler; bir sorguya özel açıp kapatamazsın. 2.0.151'den beri constructor mapping'i için de geçerli. Uygulama açılırken bir kez ayarla ve unut; neden "bir kez ve en başta" dediğimi tuzaklar bölümünde göreceksin.

dynamic Sonuçlar

Bazen sonucu taşıyacak bir class yazmak gereksiz gelir: tek seferlik bir rapor, bir yönetim ekranındaki hızlı bir liste, ya da sadece ne döndüğünü görmek istediğin bir deneme. Query'yi generic parametre vermeden çağırırsan her satır bir dynamic olarak gelir:

var rows = connection.Query(
    """
    SELECT b.title, a.name AS author, b.price
    FROM books b
    JOIN authors a ON a.id = b.author_id
    WHERE b.price > 14
    """);

foreach (var row in rows)
{
    Console.WriteLine($"{row.title} by {row.author}: {row.price}");
}

// Her satır aynı zamanda bir sözlük: kolon adı ve değer
var first = (IDictionary<string, object>)rows.First();
foreach (var (column, value) in first)
{
    Console.WriteLine($"{column} = {value} ({value.GetType().Name})");
}

Console.WriteLine(rows.First().isbn is null);
The Dispossessed by Ursula K. Le Guin: 15.25
Dune by Frank Herbert: 18.9
title = The Dispossessed (String)
author = Ursula K. Le Guin (String)
price = 15.25 (Double)
True

Gelen object'in gerçek tipi Dapper'ın DapperRow class'ı; property'lere kolon adıyla erişebilir ya da onu bir sözlük gibi gezebilirsin. Sözlük çıktısında provider'ın döndürdüğü ham tipleri görüyorsun: fiyat double. Typed sorguda Dapper bunu decimal'e çeviriyordu; burada hedef tip olmadığı için dönüşüm de yok.

Son satır önemli: tabloda isbn diye bir kolon yok, ama row.isbn exception fırlatmadı, null döndü. Yanlış yazılmış bir kolon adı dynamic dünyasında da sessizce geçer, üstelik derleyici de yardım edemez. dynamic'i keşif ve tek seferlik işler için sakla; uygulama kodunda typed sorgu yaz.

Record'lar ve Constructor Mapping'i

Okuma modelleri için record'lar çok doğal bir tercih. Dapper parametresiz constructor'ı olmayan bir tip gördüğünde, kolonlarla eşleşen bir constructor arar:

public record BookSummary(long Id, string Title, double Price);
var summaries = connection.Query<BookSummary>(
    "SELECT id, title, price FROM books WHERE author_id = 1");

foreach (var summary in summaries)
{
    Console.WriteLine(summary);
}
BookSummary { Id = 1, Title = A Wizard of Earthsea, Price = 12.5 }
BookSummary { Id = 2, Title = The Left Hand of Darkness, Price = 14 }
BookSummary { Id = 3, Title = The Dispossessed, Price = 15.25 }

Çalıştı, ama tiplere dikkat et: long ve double. Aynı record'u alışık olduğumuz tiplerle yazalım:

public record BookCard(int Id, string Title, decimal Price);
try
{
    var cards = connection.Query<BookCard>("SELECT id, title, price FROM books").ToList();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}
A parameterless default constructor or one matching signature (System.Int64 id, System.String title, System.Double price) is required for BookCard materialization

Property eşlemesinde serbest olan dönüşümler constructor mapping'inde yok. Dapper, parametre adları kolon adlarıyla, sırası kolon sırasıyla ve tipleri reader'ın döndürdüğü tiplerle uyuşan bir constructor arar. Mesaj da tam olarak aradığı imzayı söylüyor: (System.Int64 id, System.String title, System.Double price). Aynı BookSummary'yi SELECT title, id, price ile çağırdığında da benzer bir mesaj alırsın, bu kez imzada title başta. Fazla ya da eksik bir kolon da constructor'ı eşleşmez hâle getirir.

Record'dan vazgeçmeden esneklik istiyorsan property'leri init ile tanımla. Positional parametre olmadığı için derleyici parametresiz bir constructor üretir ve Dapper property mapping'ine geri döner:

public record BookListItem
{
    public int Id { get; init; }
    public string Title { get; init; } = "";
    public decimal Price { get; init; }
}
BookListItem { Id = 1, Title = A Wizard of Earthsea, Price = 12.5 }
BookListItem { Id = 2, Title = The Left Hand of Darkness, Price = 14 }
BookListItem { Id = 3, Title = The Dispossessed, Price = 15.25 }

Aynı SELECT id, title, price sorgusu, bu kez int ve decimal tiplerle sorunsuz. Positional record istiyorsan SQLite'ta long/double kullan ya da SQL Server gibi tipleri daha zengin bir provider'da kolon tipleriyle birebir uyumlu yaz. Tip dönüşümünü kendin yönetmek istediğin durumlar için 11. yazıda type handler'ları göreceğiz.

Sık Düşülen Tuzaklar

En sinsisi şu. MatchNamesWithUnderscores'u uygulamanın ortasında, bir sorgu zaten çalıştıktan sonra açarsan ne olur?

const string sql = "SELECT id, title, published_year FROM books WHERE id = 4";

Console.WriteLine(connection.QuerySingle<Book>(sql).PublishedYear);

DefaultTypeMap.MatchNamesWithUnderscores = true;
Console.WriteLine(connection.QuerySingle<Book>(sql).PublishedYear);

SqlMapper.PurgeQueryCache();
Console.WriteLine(connection.QuerySingle<Book>(sql).PublishedYear);
0
0
1965

Ayarı açtıktan sonraki ilk çağrı hâlâ 0 döndü. İlk yazıda konuştuğumuz önbellek yüzünden: Dapper bu SQL ve bu tip için mapping kodunu bir kez üretti ve sakladı; ayarı değiştirmen o hazır kodu geçersiz kılmıyor. Önbelleği temizleyince yeni ayar devreye giriyor. Bu ayarlar process genelinde geçerli, o yüzden uygulama başlarken, ilk sorgudan önce yapılır.

İkincisi SELECT *. Eşleşmeyen kolonlar sessizce atlandığı için, SELECT * tabloya sonradan eklenen kolonları da gereksiz yere taşır, bir property'nin neden boş kaldığını da gizler. Kolonları açıkça yaz.

Üçüncüsü NULL olabilen kolonları nullable olmayan value type'lara eşlemek. Gördüğün gibi exception yok, sadece 0, false ya da DateTime.MinValue gibi yanıltıcı varsayılanlar var. Şemada NULL kabul eden her kolon, C# tarafında nullable bir tipe gitmeli.

Dördüncüsü SQLite'ın tip sistemi. Constructor mapping'indeki hatanın kaynağı Dapper değil, SQLite'ın her tamsayıyı long, her ondalıklıyı double olarak döndürmesi. Aynı kod SQL Server'da INT bir kolonla sorunsuz çalışabilir; provider değiştirdiğinde positional record'larını tekrar kontrol et.

Küçük Bir Deneme

★☆☆ "Önce Tahmin Et"teki sorguda alias'ı published_year AS publishedyear olarak, tamamen küçük harfle yaz. PublishedYear dolar mı? Neden?

★★☆ BookSummary record'unu SELECT id, title, price, stock ile sorgula. Fazladan tek bir kolon neden hataya yol açıyor? Aynı kolonu BookListItem ile denediğinde ne oluyor?

★★★ BookDetails'e public string Country { get; set; } = ""; ekle ve authors tablosuyla JOIN yapıp a.country'yi de seç. MatchNamesWithUnderscores kapalıyken ve açıkken sonuçları karşılaştır. Sonra JOIN'deki iki tablonun da id kolonunu SELECT b.*, a.* ile getir ve Id'ye hangisinin yazıldığına bak.

Buraya kadar hep birden fazla satır döndürebilen Query ile çalıştık, arada QuerySingle'ı da gördük. Bir sonraki yazıda tek satır method'larının dört kardeşini, QueryFirst, QueryFirstOrDefault, QuerySingle ve QuerySingleOrDefault'u, hangi durumda hangi exception'ı fırlattıklarıyla birlikte inceleyeceğiz. Seri burada bitmiyor; Dapper'ın yeni sürümleri çıktıkça yeni yazılarla devam edeceğiz.