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

Dapper ile Multi-Mapping ve splitOn: İlişkili Verileri Tek Sorguda Okumak

Mert Özen 6 Kas 2026 11 dk 3 görüntülenme
Dapper ile Multi-Mapping ve splitOn: İlişkili Verileri Tek Sorguda Okumak

Dapper'da multi-mapping'in row'ları column dilimlerine nasıl böldüğünü, varsayılan splitOn = "Id" hatasını, özel ve virgüllü splitOn'u, 1-N için dictionary desenini ve LEFT JOIN'deki null davranışını görüyor; N+1 problemini JOIN ve IN @ids ile ölçerek çözüyoruz.

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

Önce Tahmin Et

Kitapları yazarlarıyla birlikte tek bir JOIN ile okuyup her Book'un Author property'sini doldurmak istiyoruz. Dapper'a iki tip veriyoruz, bir de iki object'i birleştiren küçük bir fonksiyon. Bu kod ekrana ne basar?

await using var conn = new SqliteConnection(cs);

try
{
    var books = await conn.QueryAsync<Book, Author, Book>(
        """
        select b.id, b.title, b.price, a.name, a.country
        from books b
        join authors a on a.id = b.author_id
        """,
        (book, author) =>
        {
            book.Author = author;
            return book;
        });
    Console.WriteLine(books.First().Author?.Name);
}
catch (Exception ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

"Ursula K. Le Guin" diyorsan SQL'e bir daha bak. Cevap, multi-mapping'in nasıl çalıştığını anlamanın en kısa yolu.

Hazırlık

Paketler aynı: Dapper 2.1.89 ve Microsoft.Data.Sqlite 10.0.14. Bu yazıda dört tablonun hepsi var. Stanislaw Lem'in bilerek hiç kitabı yok; LEFT JOIN'de işimize yarayacak.

using Dapper;
using Microsoft.Data.Sqlite;

public static class Setup
{
    public static async Task CreateAsync(string cs)
    {
        if (File.Exists("bookstore.db")) File.Delete("bookstore.db");
        await using var conn = new SqliteConnection(cs);
        await conn.ExecuteAsync("""
            create table authors (
                id      integer primary key,
                name    text not null,
                country text not null
            );
            create table books (
                id        integer primary key,
                author_id integer not null references authors(id),
                title     text not null,
                price     real not null
            );
            create table orders (
                id         integer primary key,
                customer   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, 'Ted Chiang', 'USA'),
                (4, 'Stanislaw Lem', 'Poland');
            insert into books (id, author_id, title, price) values
                (1, 1, 'A Wizard of Earthsea', 9.99),
                (2, 1, 'The Left Hand of Darkness', 12.50),
                (3, 2, 'Dune', 14.99),
                (4, 2, 'Dune Messiah', 11.99),
                (5, 3, 'Exhalation', 16.00);
            insert into orders (id, customer, created_at) values
                (1, 'Ada', '2026-11-04T10:00:00Z');
            insert into order_items (id, order_id, book_id, quantity, unit_price) values
                (1, 1, 3, 2, 14.99),
                (2, 1, 5, 1, 16.00);
            """);
    }
}

public sealed class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Country { get; set; } = "";
    public List<Book> Books { get; } = [];
}

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

Her örnek const string cs = "Data Source=bookstore.db"; ve await Setup.CreateAsync(cs); ile başlıyor.

Cevap: splitOn Bir Column Arıyor

ArgumentException: Multi-map error: splitOn column 'Id' was not found - please ensure your splitOn parameter is set and in the correct order (Parameter 'splitOn')

Row'lar hiç okunmadan exception geldi. Multi-mapping'in mantığı şu: Dapper sonuçtaki her row'u soldan sağa column dilimlerine böler ve her dilimi sırayla bir tipe eşler. Hangi column'da yeni bir dilimin başladığını splitOn parametresi söyler ve varsayılan değeri "Id". Dapper'ın kaynağına bakınca bölme noktalarını sağdan sola aradığını, büyük-küçük harf ayırmadan karşılaştırdığını ve ilk column'u hiçbir zaman bölme noktası saymadığını görüyorsun. Bizim sorguda b.id ilk column, yazar kısmında ise hiç id yok. Dapper Author'ın nereden başladığını bulamıyor ve vazgeçiyor.

Bu hatayı bir kez anladığında multi-mapping'in geri kalanı yerine oturuyor: SELECT listesindeki column sırası, generic parametrelerin sırasıyla birebir aynı olmalı ve her yeni tipin ilk column'u splitOn'da adı geçen column olmalı.

1-1: Kitap ve Yazarı

En doğal düzeltme, yazar dilimini a.id ile başlatmak:

var books = await conn.QueryAsync<Book, Author, Book>(
    """
    select b.id, b.title, b.price, a.id, a.name, a.country
    from books b
    join authors a on a.id = b.author_id
    order by b.id
    """,
    (book, author) =>
    {
        book.Author = author;
        return book;
    });

foreach (var book in books)
{
    Console.WriteLine($"{book.Id} {book.Title} by {book.Author!.Name} (author {book.Author.Id})");
}
1 A Wizard of Earthsea by Ursula K. Le Guin (author 1)
2 The Left Hand of Darkness by Ursula K. Le Guin (author 1)
3 Dune by Frank Herbert (author 2)
4 Dune Messiah by Frank Herbert (author 2)
5 Exhalation by Ted Chiang (author 3)

Generic parametrelerin son tipi, yani üçüncü Book, sonucun tipi. İlk ikisi her row'dan üretilecek object'ler. Map fonksiyonu her row için bir kez çağrılıyor, sana o row'dan üretilmiş taze bir Book ve taze bir Author veriyor; onları istediğin gibi bağlayıp döndürüyorsun. İki tabloda da id adında bir column olması sorun değil, çünkü her dilim ayrı ayrı eşleniyor. b.id Book.Id'ye, a.id Author.Id'ye gidiyor.

splitOn'u Kendin Belirlemek

splitOn'un primary key olması gerekmiyor; sadece "bir sonraki object bu column'dan başlar" demek. Tahmin örneğindeki sorguyu değiştirmeden düzeltmenin yolu, bölme noktasını name yapmak:

var cheap = await conn.QueryAsync<Book, Author, Book>(
    """
    select b.id, b.title, b.price, a.name, a.country
    from books b
    join authors a on a.id = b.author_id
    where b.price < @maxPrice
    order by b.price
    """,
    (book, author) =>
    {
        book.Author = author;
        return book;
    },
    new { maxPrice = 12m },
    splitOn: "name");

foreach (var book in cheap)
{
    Console.WriteLine($"{book.Title}: {book.Price} ({book.Author!.Name}, id={book.Author.Id})");
}
A Wizard of Earthsea: 9.99 (Ursula K. Le Guin, id=0)
Dune Messiah: 11.99 (Frank Herbert, id=0)

Çalıştı, ama Author.Id 0 kaldı, çünkü SELECT'te yazarın id'si yok. Bu bazen tam istediğin şeydir: sadece ekranda göstereceğin bir yazar adı için id taşımana gerek yok. Parameter'ların da splitOn'dan önce, normal yerinde gittiğine dikkat et.

İkiden fazla tip olduğunda splitOn'a virgülle ayrılmış bir liste verirsin; her element bir sonraki tipin başladığı column. Sipariş kalemlerini kitap ve yazarla okuyalım:

DefaultTypeMap.MatchNamesWithUnderscores = true;

var lines = await conn.QueryAsync<OrderLine, Book, Author, OrderLine>(
    """
    select oi.quantity, oi.unit_price,
           b.title, b.price,
           a.name, a.country
    from order_items oi
    join books b on b.id = oi.book_id
    join authors a on a.id = b.author_id
    where oi.order_id = @orderId
    """,
    (line, book, author) =>
    {
        book.Author = author;
        line.Book = book;
        return line;
    },
    new { orderId = 1 },
    splitOn: "title,name");

foreach (var line in lines)
{
    Console.WriteLine($"{line.Quantity} x {line.Book!.Title} ({line.Book.Author!.Name}) = {line.Quantity * line.UnitPrice}");
}

public sealed class OrderLine
{
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public Book? Book { get; set; }
}
2 x Dune (Frank Herbert) = 29.98
1 x Exhalation (Ted Chiang) = 16

Her dilim id ile başlasaydı tek bir "id" (ya da hiç splitOn) yeterdi; tek değer her bölme noktası için kullanılır. MatchNamesWithUnderscores ise unit_price'ı UnitPrice'a eşlemek için; 2. yazıda konuştuğumuz global ayar, multi-mapping'de de aynen geçerli.

1-N: Yazar ve Kitapları

Ters yönden bakalım: yazarları ve her yazarın kitap listesini istiyoruz. JOIN bir yazarı, kitabı kadar row'da tekrar eder. Dapper ise her row için yeni bir Author üretir; aynı yazarın iki row'u olduğunu bilmez. Önce bunu görelim:

const string sql = """
    select a.id, a.name, a.country, b.id, b.title, b.price
    from authors a
    left join books b on b.author_id = a.id
    order by a.id, b.id
    """;

var naive = (await conn.QueryAsync<Author, Book?, Author>(sql, (author, book) =>
{
    if (book is not null) author.Books.Add(book);
    return author;
})).AsList();

Console.WriteLine($"Rows: {naive.Count}, same Le Guin instance: {ReferenceEquals(naive[0], naive[1])}");
Rows: 6, same Le Guin instance: False

Dört yazar için altı Author object'i, her birinin Books listesinde en fazla bir kitap. Çözüm, map fonksiyonunun dışında bir Dictionary tutup aynı id'yi gördüğünde var olan object'i kullanmak:

var authors = new Dictionary<int, Author>();

await conn.QueryAsync<Author, Book?, Author>(sql, (author, book) =>
{
    if (!authors.TryGetValue(author.Id, out var existing))
    {
        existing = author;
        authors.Add(existing.Id, existing);
    }

    if (book is not null)
    {
        book.Author = existing;
        existing.Books.Add(book);
    }

    return existing;
});

foreach (var author in authors.Values)
{
    var titles = author.Books.Count == 0 ? "-" : string.Join(", ", author.Books.Select(b => b.Title));
    Console.WriteLine($"{author.Name}: {titles}");
}
Ursula K. Le Guin: A Wizard of Earthsea, The Left Hand of Darkness
Frank Herbert: Dune, Dune Messiah
Ted Chiang: Exhalation
Stanislaw Lem: -

Sonucu QueryAsync'in dönüş değerinden değil authors.Values'tan alıyoruz; dönüş değeri hâlâ row başına bir element içerir. Lem'in row'una da bak: LEFT JOIN kitap column'larını null getirdi ve Dapper book parametresine null verdi. Kural kaynakta açık: ilk tip dışındaki bir dilimin ilk column'u, yani bölme column'u null ise Dapper o object'i hiç üretmez. Bu yüzden generic parametrede Book? yazdık; NRT açıkken derleyici de null kontrolünü hatırlatıyor.

N+1 Problemi

Multi-mapping'in olmadığı bir dünyada aynı veriyi çoğu kişi şöyle okur: önce yazarları çek, sonra her yazar için kitaplarını sor.

var queries = 0;
var authors = (await conn.QueryAsync<Author>("select id, name, country from authors")).AsList();
queries++;
foreach (var author in authors)
{
    var books = await conn.QueryAsync<Book>(
        "select id, title, price from books where author_id = @authorId",
        new { authorId = author.Id });
    queries++;
    author.Books.AddRange(books);
}

Bir sorgu yazarlar için, N sorgu da kitaplar için: N+1. Kod okunaklı, küçük veride fark edilmez, bu yüzden en sinsi performans hatalarından biri. Alternatifi tek JOIN ya da iki sorgu. İkinci yolda kitapları tek seferde, IN @ids liste genişletmesiyle çekip bellekte gruplarız:

var authors2 = (await conn.QueryAsync<Author>("select id, name, country from authors")).AsList();
var ids = authors2.Select(a => a.Id).ToArray();
var rows = await conn.QueryAsync<(int AuthorId, int Id, string Title, decimal Price)>(
    "select author_id, id, title, price from books where author_id in @ids", new { ids });
var byAuthor = rows.ToLookup(r => r.AuthorId);
foreach (var author in authors2)
{
    author.Books.AddRange(byAuthor[author.Id].Select(r => new Book { Id = r.Id, Title = r.Title, Price = r.Price, Author = author }));
}

Burada value tuple kullandık; Dapper tuple element'lerini isimle değil sırayla eşler. Veritabanına 496 yazar ve her birine dört kitap ekleyip üç yöntemi aynı programda üç tur çalıştırdım. İkinci turun çıktısı:

N+1:      501 queries, 1989 books, 16 ms
IN @ids:  2 queries, 1989 books, 3 ms
JOIN:     1 query, 1989 books, 2 ms

SQLite süreç içinde çalıştığı için burada ağ gecikmesi yok; fark tamamen command başına sabit maliyetten geliyor. Sunucu veritabanında her round trip'e bir de ağ gecikmesi eklenir: 1 ms'lik bir gecikmeyle 501 sorgu, daha hiçbir row okunmadan yarım saniye demek.

JOIN ile iki sorgu arasında seçim yaparken tradeoff şu. JOIN tek round trip'tir ama parent column'larını her child row'da tekrar taşır; bir parent'a iki farklı collection'ı aynı anda JOIN'lersen row sayısı çarpılarak büyür. İki sorgu yaklaşımında tekrar yok, her tablo bir kez okunur; bedeli ikinci round trip ve IN listesinin boyutu. SQL Server'da bir command'da en fazla 2100 parameter olabildiği için çok uzun listelerde farklı bir yol gerekir; 13. yazıda table-valued parameter'larla buna döneceğiz. Bu ikisinin ortası da var: iki sorguyu tek command'da gönderip iki ayrı result set okumak. Bir sonraki yazının konusu tam olarak bu.

Sık Düşülen Tuzaklar

JOIN'li sorguda select * yazmak. Column sırası tablo tanımına bağlı kalır, birine bir column eklendiğinde bölme noktaları kayar ve hatasız ama yanlış mapping yaparsın. Column'ları açıkça, generic parametre sırasıyla yaz.

SELECT sırasıyla generic parametre sırasını uyumsuz bırakmak. Dapper isimlere değil dilimlere bakar; Query<Book, Author, Book> dediysen ilk dilim kitap column'ları olmalı.

splitOn'daki column'un ilk tip içinde de geçmesi. Dapper sağdan sola aradığı için çoğu durumda doğru noktayı bulur, ama name gibi genel bir isimle böldüğünde ilk tipte de name varsa sürprizlere hazır ol. Bölme column'u olarak gerçekten ayırt edici bir column seç ya da alias ver.

1-N'de dictionary'yi unutup dönüş değerini kullanmak. Tekrarlanan parent'lar ve tek element'li listeler alırsın.

INNER JOIN ile LEFT JOIN farkını unutmak. Kitabı olmayan yazarlar INNER JOIN'de sonuçta hiç yer almaz; LEFT JOIN'de ise child tipini nullable yazıp null kontrolü yapman gerekir.

EF Core'dan gelenler için: Include(a => a.Books) bu birleştirmeyi senin yerine yapar ve aynı yazarı tek object'te toplar. Dapper'da JOIN'i, bölme noktalarını ve tekrarlanan parent'ları ayıklamayı sen yazarsın. Karşılığında hangi SQL'in gittiğini tam olarak bilirsin.

Küçük Bir Deneme

★☆☆ 1-1 örneğindeki SELECT'te a.id'yi listenin sonuna taşı. Hangi hatayı ya da hangi yanlış mapping'i görüyorsun? Column sırasının neden bu kadar önemli olduğunu çıktıdan oku.

★★☆ OrderLine örneğini her dilim id ile başlayacak şekilde yeniden yaz (oi.id, b.id, a.id) ve splitOn parametresini tamamen kaldır. Çıktının aynı kaldığını doğrula.

★★★ Yazarları kitaplarıyla birlikte, her kitabın da sipariş kalemleriyle birlikte geldiği iki seviyeli bir yapı kur: Query<Author, Book?, OrderLine?, Author>. Hem yazarları hem kitapları ayrı dictionary'lerle tekrar etmeyecek şekilde topla ve row sayısının nasıl büyüdüğünü gözlemle.

Bir sonraki yazıda tek command'la birden fazla result set döndüreceğiz: QueryMultiple ve GridReader ile sipariş başlığını, kalemlerini ve toplamını tek round trip'te okuyacak, multi-mapping ile ne zaman hangisini seçeceğimizi konuşacağız. Seri, Dapper'ın yeni sürümleri çıktıkça güncellenmeye devam edecek.