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

Dapper ile QueryMultiple ve GridReader: Tek Round Trip'te Birden Fazla Result Set

Mert Özen 6 Kas 2026 10 dk 2 görüntülenme
Dapper ile QueryMultiple ve GridReader: Tek Round Trip'te Birden Fazla Result Set

Dapper'da QueryMultiple ve GridReader ile tek command'dan birden fazla result set okuyoruz: grid'lerin neden sırayla ve bir kez okunduğunu, master-detail bir siparişi tek round trip'te yüklemeyi, iki result set'i bellekte birleştirmeyi, unbuffered grid tuzağını ve multi-mapping ile ne zaman hangisini seçeceğimizi gerçek çıktılarla görüyoruz.

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

Önce Tahmin Et

Tek bir command'la iki select gönderiyoruz: önce yazarı, sonra o yazarın kitaplarını. İki result set'i de okuduktan sonra, sanki bir üçüncüsü varmış gibi bir kez daha Read çağırıyoruz. Üçüncü Read boş bir liste mi döner, null mı, yoksa exception mı fırlatır?

await using var conn = new SqliteConnection(cs);

using (var grid = conn.QueryMultiple("""
    select id, name, country from authors where id = @id;
    select id, title, price from books where author_id = @id;
    """, new { id = 2 }))
{
    var author = grid.ReadSingle<Author>();
    var books = grid.Read<Book>().AsList();
    Console.WriteLine($"{author.Name}: {books.Count} books, IsConsumed={grid.IsConsumed}");
    try
    {
        var extra = grid.Read<Book>();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
    }
}

Tahminini bir kenara yaz; önce kurulum.

Hazırlık

Paketler aynı: Dapper 2.1.89 ve Microsoft.Data.Sqlite 10.0.14. Tablolar ve veriler bir önceki yazıdakiyle aynı: dört yazar (Stanislaw Lem'in kitabı yok), beş kitap, bir sipariş ve iki sipariş kalemi.

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

Modellerde tek bir fark var: Book'a bir AuthorId property'si ekliyoruz, çünkü iki ayrı result set'i bellekte birleştirirken ona ihtiyacımız olacak:

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 int AuthorId { 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: Grid'ler Sırayla ve Bir Kez Okunur

Frank Herbert: 2 books, IsConsumed=True
ObjectDisposedException: The reader has been disposed; this can happen after all data has been consumed
Object name: 'Dapper.SqlMapper+GridReader'.

Exception geldi, hem de ObjectDisposedException. QueryMultiple command'ı çalıştırıp sana bir SqlMapper.GridReader verir. GridReader, altta açık duran tek bir data reader'ın etrafındaki ince bir sarmalayıcı. Her Read çağrısı sıradaki result set'i, yani bir "grid"i okur ve reader'ı bir sonrakine ilerletir. Son grid de tüketildiğinde Dapper beklemeden reader'ı kapatır; IsConsumed=True bunu gösteriyor. Bu noktadan sonra her Read, kapanmış bir reader'a erişmeye çalıştığı için bu exception'la karşılanır.

Kuralın diğer yarısı sıra. Grid'ler SQL'deki statement sırasıyla gelir ve Dapper hangi grid'in hangi tipe ait olduğunu bilmez. İlk grid'i yanlış tiple okursan ne olacağına bakalım:

using (var grid = conn.QueryMultiple("""
    select id, name, country from authors where id = @id;
    select id, title, price from books where author_id = @id;
    """, new { id = 2 }))
{
    var wrong = grid.Read<Book>().AsList();
    Console.WriteLine($"first grid as Book: {wrong.Count} row, Id={wrong[0].Id}, Title='{wrong[0].Title}'");
}
first grid as Book: 1 row, Id=2, Title=''

Hata yok. Yazarın id'si Book.Id'ye oturdu, name ve country için Book'ta karşılık olmadığından görmezden gelindi, Title boş kaldı. 2. yazıda gördüğümüz "eşleşmeyen column sessizce atlanır" davranışı burada da geçerli; yanlış sıra exception değil yanlış veri üretir. Read çağrılarını SQL'deki sırayla, satır satır eşleştirerek yaz.

Master-Detail: Sipariş, Kalemler ve Toplam

QueryMultiple'ın en tipik kullanımı master-detail ekranlar: bir sipariş başlığı, kalemleri ve özet bilgi. Üçünü tek round trip'te alan bir method yazalım. Kalemleri okurken bir önceki yazıdaki multi-mapping'i de grid içinde kullanacağız:

DefaultTypeMap.MatchNamesWithUnderscores = true;

await using var conn = new SqliteConnection(cs);

var details = await GetOrderDetailsAsync(conn, orderId: 1);
if (details is not null)
{
    Console.WriteLine($"Order {details.Order.Id} for {details.Order.Customer}, {details.Lines.Count} lines, total {details.Total}");
    foreach (var line in details.Lines)
    {
        Console.WriteLine($"  {line.Quantity} x {line.Book!.Title} by {line.Book.Author!.Name}");
    }
}

var missing = await GetOrderDetailsAsync(conn, orderId: 42);
Console.WriteLine($"Order 42: {(missing is null ? "not found" : "found")}");

static async Task<OrderDetails?> GetOrderDetailsAsync(DbConnection conn, int orderId)
{
    await using var grid = await conn.QueryMultipleAsync("""
        select id, customer, created_at from orders where id = @orderId;

        select oi.quantity, oi.unit_price, b.id, b.title, b.price, a.id, 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
        order by oi.id;

        select coalesce(sum(quantity * unit_price), 0) from order_items where order_id = @orderId;
        """, new { orderId });

    var order = await grid.ReadSingleOrDefaultAsync<Order>();
    if (order is null) return null;

    var lines = grid.Read<OrderLine, Book, Author, OrderLine>((line, book, author) =>
    {
        book.Author = author;
        line.Book = book;
        return line;
    }).AsList();

    var total = await grid.ReadSingleAsync<decimal>();
    return new OrderDetails(order, lines, total);
}

public sealed record OrderDetails(Order Order, IReadOnlyList<OrderLine> Lines, decimal Total);

public sealed class Order
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
    public string CreatedAt { get; set; } = "";
}

public sealed class OrderLine
{
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public Book? Book { get; set; }
}
Order 1 for Ada, 2 lines, total 45.98
  2 x Dune by Frank Herbert
  1 x Exhalation by Ted Chiang
Order 42: not found

Birkaç şeyi tek tek söyleyelim. Üç statement aynı parameter object'ini paylaşıyor; @orderId bir kez tanımlanıp her statement'ta kullanılıyor. ReadSingleOrDefaultAsync, ReadFirstAsync, ReadAsync<T> gibi method'lar 3. yazıdaki tek row'luk sorgu ailesinin grid'deki karşılıkları ve aynı kurallarla exception fırlatıyor. Kalemleri okurken splitOn vermedik, çünkü her dilim id ile başlıyor ve GridReader'daki multi-mapping'in varsayılanı da "id".

Bir ayrıntı daha: multi-mapping yapan Read<TFirst, TSecond, ..., TReturn> overload'larının async karşılığı yok; GridReader'ın kaynağında async method'lar yalnızca tek tipli okumalar için tanımlı. SQLite'ta bunun pratik bir önemi yok, sunucu veritabanlarında ise o grid'in row'ları senkron okunuyor demek.

Sipariş 42 bulunamayınca method erkenden döndü ve iki grid hiç okunmadı. Bu bir sorun değil: await using GridReader'ı dispose ederken kalan result set'leri ve reader'ı kapatıyor. Connection'ı da biz açmadık; Dapper kapalı connection'ı QueryMultipleAsync için açtı ve GridReader dispose edilince kapattı.

İki Result Set'i Bellekte Birleştirmek

Önceki yazıda 1-N ilişkiyi JOIN ve dictionary ile kurmuştuk. Aynı sonucu iki grid'le de alabiliriz: bir grid'de yazarlar, diğerinde kitaplar.

await using var grid = await conn.QueryMultipleAsync("""
    select id, name, country from authors where country = @country order by id;

    select b.id, b.author_id, b.title, b.price
    from books b
    join authors a on a.id = b.author_id
    where a.country = @country
    order by b.id;
    """, new { country = "USA" });

var authors = (await grid.ReadAsync<Author>()).AsList();
var booksByAuthor = (await grid.ReadAsync<Book>()).ToLookup(b => b.AuthorId);

foreach (var author in authors)
{
    author.Books.AddRange(booksByAuthor[author.Id]);
    Console.WriteLine($"{author.Name}: {string.Join(", ", author.Books.Select(b => b.Title))}");
}
Ursula K. Le Guin: A Wizard of Earthsea, The Left Hand of Darkness
Frank Herbert: Dune, Dune Messiah
Ted Chiang: Exhalation

Dictionary yok, tekrarlanan yazar row'u yok, map fonksiyonu yok. Her yazar bir kez, her kitap bir kez geldi; ToLookup da kitapları AuthorId'ye göre grupladı. İkinci sorgunun filtresini birinciyle aynı tutmak senin sorumluluğun: burada iki statement da country = @country ile süzülüyor. Bu tekrarı istemiyorsan ilk statement'ın sonucunu bir geçici tabloya yazıp ikincisinde onunla JOIN'lemek de bir seçenek.

Unbuffered Grid'ler

Read<T> varsayılan olarak buffered çalışır: grid'in tüm row'larını bir List<T>'ye okur, sonra reader'ı bir sonraki grid'e ilerletir. buffered: false verdiğinde row'lar sen enumerate ettikçe okunur. Bu, sıradaki grid'e geçmeden önce o grid'i sonuna kadar tüketmen gerektiği anlamına geliyor:

using (var grid = conn.QueryMultiple("""
    select id, name, country from authors;
    select id, title, price from books;
    """))
{
    try
    {
        var authors = grid.Read<Author>(buffered: false);
        var books = grid.Read<Book>().AsList();
        Console.WriteLine($"books {books.Count}");
        Console.WriteLine($"authors {authors.Count()}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
    }
}
Console.WriteLine($"state: {conn.State}");
InvalidOperationException: Query results must be consumed in the correct order, and each result can only be consumed once
state: Closed

İkinci Read çağrısı, ilk grid hâlâ tüketilmeyi beklerken geldi ve Dapper bunu reddetti. Async tarafta aynı fikrin karşılığı 2.0.138'den beri var olan ReadUnbufferedAsync<T>(); IAsyncEnumerable<T> döner ve await foreach ile tüketilir. Büyük result set'lerde buffered ile unbuffered arasındaki farkı bir sonraki yazıda ölçerek konuşacağız.

QueryMultiple mi, Multi-Mapping mi?

İkisi de N+1'den kurtarır ve ikisi de tek round trip'tir. Seçim, verinin şekline bağlı.

Multi-mapping tek bir row'un içinde birden fazla object taşır. 1-1 ilişkilerde (kitap ve yazarı, kalem ve kitabı) rakipsizdir; JOIN tekrar üretmez, kod da kısa kalır. 1-N'de parent column'larını her child row'da tekrar taşır ve dictionary ile ayıklama gerektirir. Bir parent'a iki collection JOIN'lediğinde row sayısı çarpılır.

QueryMultiple her tabloyu ayrı bir result set olarak getirir. Tekrar yoktur, birbiriyle ilişkisi olmayan verileri de aynı round trip'e koyabilirsin; master-detail ekranında başlık, kalemler ve özet gibi. Bedeli, filtreyi her statement'ta tekrar etmen ve sonuçları bellekte birleştirmen. Ben 1-1 ve küçük 1-N için multi-mapping, birden fazla collection ya da birbirinden bağımsız sorgular için QueryMultiple kullanıyorum ve çoğu master-detail ekranında ikisini birlikte, yukarıdaki sipariş örneğindeki gibi.

Provider tarafında da bir not: tek command'da birden fazla statement göndermek SQLite, SQL Server ve PostgreSQL'de destekleniyor, ama her provider için geçerli değil. Bir de Dapper.AOT'u planlıyorsan, QueryMultiple onun intercept ettiği çağrılar arasında yok; çağrı klasik Dapper'a düşüyor. Bunu serinin son yazısında açacağız.

Sık Düşülen Tuzaklar

Read çağrılarını SQL'deki statement sırasından farklı yazmak. Exception değil yanlış veri alırsın; özellikle iki grid'in column'ları birbirine benziyorsa fark etmesi zordur.

Grid sayısından fazla Read çağırmak ya da bir grid'i iki kez okumaya çalışmak. İlki ObjectDisposedException, ikincisi "consumed in the correct order" mesajlı InvalidOperationException üretir.

GridReader'ı dispose etmemek. Altındaki reader açık kalır, connection'ı Dapper açtıysa o da açık kalır. using ya da await using her zaman.

Bir grid'i buffered: false ile okuyup tüketmeden sıradakine geçmek.

Filtreleri statement'lar arasında senkron tutmamak. Birinci grid'i country = 'USA' ile, ikinciyi filtresiz yazarsan lookup'ta hiçbir yazara ait olmayan kitaplar birikir.

EF Core'dan gelenler için: EF Core'un AsSplitQuery() seçeneği, collection Include'larını ayrı sorgulara bölerek benzer bir problemi çözer. Dapper'da bu bölmeyi QueryMultiple ile kendin yaparsın ve sorguların aynı round trip'e mi gideceğine de sen karar verirsin.

Küçük Bir Deneme

★☆☆ Tahmin örneğindeki iki statement'ın yerini değiştir ama Read çağrılarını olduğu gibi bırak. ReadSingle<Author>() ne yapıyor, neden?

★★☆ GetOrderDetailsAsync'e dördüncü bir grid ekle: siparişteki kitapların yazarlarının toplam kitap sayısı (select a.name, count(*) ...). Sonucu OrderDetails record'una yeni bir property olarak taşı.

★★★ İki grid'li yazar-kitap örneğini buffered: false ile yeniden yaz: yazarları buffered, kitapları ReadUnbufferedAsync<Book>() ile await foreach içinde okuyup lookup yerine doğrudan dictionary'deki yazara ekle. Hangi okuma sırasının zorunlu olduğunu ve nedenini açıkla.

Şimdiye kadar okuduğumuz her result set belleğe sığacak kadar küçüktü. Bir milyon row'luk bir raporda ise buffered okuma yüzlerce megabayt demek. Bir sonraki yazıda buffered: false ve QueryUnbufferedAsync ile row'ları akıtarak okuyacak, belleği ve davranış farklarını gerçek ölçümlerle göreceğiz. Seri, Dapper'ın yeni sürümleri çıktıkça güncellenmeye devam edecek.