Dapper ile Dinamik SQL: SqlBuilder, Güvenli Filtreler ve Contrib, Dommel, Dapper Plus
Opsiyonel filtreleri string birleştirmeden kurmak: Dapper.SqlBuilder'ın template ve placeholder mantığı, aynı builder'la sayım ve sayfa sorgusu, whitelist ile güvenli sıralama ve OrWhere'in sürprizi. Ardından Dapper.Contrib, Rainbow, Dommel ve ticari Dapper Plus için Kasım 2026 itibarıyla dengeli bir değerlendirme.
Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.
Önce Tahmin Et
Bookstore'un arama ekranında iki opsiyonel filtre var: başlıkta geçen bir kelime ve en yüksek fiyat. Kullanıcı yalnızca fiyatı doldurdu. Bu kod hangi SQL'i üretir?
var builder = new SqlBuilder();
var template = builder.AddTemplate("select id, title, price from books /**where**/ /**orderby**/");
string? titleContains = null;
decimal? maxPrice = 12m;
if (titleContains is not null)
builder.Where("title like @pattern", new { pattern = $"%{titleContains}%" });
if (maxPrice is not null)
builder.Where("price <= @maxPrice", new { maxPrice });
builder.OrderBy("price desc");
Console.WriteLine(template.RawSql);
foreach (var b in conn.Query<BookRow>(template.RawSql, template.Parameters))
Console.WriteLine($"{b.Id} | {b.Title} | {b.Price}");
public record BookRow(long Id, string Title, double Price);
Dikkat etmen gereken üç yer var: /**where**/ yerine ne gelecek, WHERE kelimesini kim yazacak ve titleContains için eklenmeyen filtre SQL'de bir iz bırakacak mı?
select id, title, price from books WHERE price <= @maxPrice
ORDER BY price desc
3 | The Left Hand of Darkness | 11
2 | Dune Messiah | 10
WHERE ve ORDER BY kelimelerini SqlBuilder yazdı, eklenmeyen filtre iz bırakmadı ve değer bir parameter olarak gitti. Hiç filtre eklemeseydik placeholder'lar tamamen silinecek, geriye select id, title, price from books kalacaktı. Bu yazıda dinamik SQL'i string birleştirmeden kurmanın Dapper'daki yolunu ve Dapper'ın etrafındaki paketleri ele alıyoruz.
String Birleştirmenin Bedeli
SqlBuilder'ın neden var olduğunu görmek için kısa yoldan gidelim. Arama terimini doğrudan SQL'e gömüyoruz:
var term = "x' or 1=1 or title like '";
var sql = $"select title from books where title like '%{term}%'";
Console.WriteLine(sql);
Console.WriteLine(conn.Query<string>(sql).Count());
select title from books where title like '%x' or 1=1 or title like '%'
4
Kullanıcı tek bir arama kutusuyla bütün tabloyu okudu. 04. yazıdaki kural değişmedi: Dapper yalnızca param olarak verdiğin değerleri parameter'a çevirir; SQL metnine gömdüğün her şey olduğu gibi gider. İlk örnekte $"%{titleContains}%" interpolation'ı da vardı, ama o SQL'e değil parameter'ın değerine gidiyordu. Fark tam olarak bu.
Opsiyonel filtreler için klasik çözüm where 1 = 1 ile başlayıp her filtrede " and ..." eklemek ve parameter'ları bir DynamicParameters'ta biriktirmektir. Çalışır, ama and'leri, boşlukları ve hangi parameter'ın eklendiğini elle izlemen gerekir. SqlBuilder bu muhasebeyi üstlenir.
SqlBuilder'ın Mantığı: Template ve Placeholder'lar
SqlBuilder ayrı bir paket ama Dapper'ın kendi reposunda ve aynı sürüm numarasıyla yayınlanıyor:
dotnet add package Dapper.SqlBuilder --version 2.1.89
Mantık bir form doldurmak gibi. AddTemplate boşlukları olan bir form verir: /**where**/, /**orderby**/, /**select**/, /**innerjoin**/, /**leftjoin**/, /**groupby**/, /**having**/ gibi. Where, OrderBy, InnerJoin gibi method'lar bu boşluklara parça ekler. RawSql'i okuduğun anda parçalar birleşir: aynı türden parçalar uygun bağlaçla (Where için AND, OrderBy için virgül) yan yana gelir ve başlarına anahtar kelime eklenir. Doldurulmamış her placeholder silinir.
Parameters property'si ise template'e verdiğin parameter'larla her parçanın parameter'larını tek bir DynamicParameters'ta toplar. Bu yüzden Query'ye hep ikisini birlikte veriyoruz: template.RawSql ve template.Parameters.
Aynı Builder, İki Template: Sayım ve Sayfa
SqlBuilder'ın en sevdiğim özelliği, bir builder'a birden fazla template bağlanabilmesi. Sayfalı bir listede hem toplam kaydı hem o sayfanın satırlarını aynı filtrelerle istiyorsun:
var builder = new SqlBuilder();
var count = builder.AddTemplate("select count(*) from books b /**innerjoin**/ /**where**/");
var page = builder.AddTemplate("""
select b.id, b.title, b.price
from books b /**innerjoin**/ /**where**/ /**orderby**/
limit @take offset @skip
""", new { take = 1, skip = 0 });
builder.InnerJoin("authors a on a.id = b.author_id");
builder.Where("a.name = @author", new { author = "Frank Herbert" });
builder.OrderBy("b.price");
var total = conn.ExecuteScalar<int>(count.RawSql, count.Parameters);
var rows = conn.Query<BookRow>(page.RawSql, page.Parameters).AsList();
Console.WriteLine($"{rows.Count} of {total}: {rows[0].Title}");
Console.WriteLine(page.RawSql);
1 of 2: Dune Messiah
select b.id, b.title, b.price
from books b
INNER JOIN authors a on a.id = b.author_id
WHERE a.name = @author
ORDER BY b.price
limit @take offset @skip
Join ve filtre bir kez yazıldı, iki sorguya da girdi. Sayım template'inde /**orderby**/ olmadığı için sıralama oraya hiç taşınmadı; gereksiz bir ORDER BY'ı sayım sorgusundan uzak tutmak da bedava geldi. take ve skip ise yalnızca sayfa template'ine ait parameter'lar.
Kullanıcının Seçtiği Sıralama
Burada SqlBuilder'ın sınırına çarpıyoruz. Where ve OrderBy'a verdiğin metin parameter değil, ham SQL. Sıralama kolonunu query string'den alıp builder.OrderBy(sortKey) yazarsan string birleştirmenin bütün riskleri geri döner. Kolon adları parameter olamaz; tek güvenli yol, izin verilen değerleri koddan seçmek:
partial class Program
{
static readonly Dictionary<string, string> SortColumns = new(StringComparer.OrdinalIgnoreCase)
{
["title"] = "title",
["price"] = "price",
["newest"] = "id desc"
};
}
var builder = new SqlBuilder();
var template = builder.AddTemplate("select id, title, price from books /**orderby**/");
builder.OrderBy(SortColumns.GetValueOrDefault(sortKey, "title"));
Console.WriteLine(template.RawSql);
Console.WriteLine(string.Join(", ", conn.Query<BookRow>(template.RawSql).Select(b => b.Id)));
sortKey olarak "newest" ve "price; drop table books" verdiğimde:
select id, title, price from books ORDER BY id desc
4, 3, 2, 1
select id, title, price from books ORDER BY title
1, 2, 4, 3
Kullanıcının gönderdiği metin SQL'e hiç değmiyor; yalnızca sözlükteki bir anahtarı seçiyor. Tanımadığımız her değer varsayılan sıralamaya düşüyor. Aynı kural tablo adları, kolon listeleri ve asc/desc yönü için de geçerli.
OrWhere'in Sürprizi
SqlBuilder'ın bir de OrWhere method'u var ve davranışı adından tahmin ettiğin gibi değil. 2.1.72'de dokümantasyonuna bunun için ayrı bir uyarı eklendi. Deneyelim:
var builder = new SqlBuilder();
var template = builder.AddTemplate("select title from books /**where**/");
builder.Where("author_id = @authorId", new { authorId = 1 });
builder.OrWhere("price > @minPrice", new { minPrice = 12.0 });
builder.Where("title like @pattern", new { pattern = "%Dune%" });
Console.WriteLine(template.RawSql);
Console.WriteLine(string.Join(", ", conn.Query<string>(template.RawSql, template.Parameters)));
select title from books WHERE author_id = @authorId AND title like @pattern AND ( price > @minPrice )
Dune
Çağrı sırası önemsenmedi. SqlBuilder bütün Where'leri kendi aralarında, bütün OrWhere'leri kendi aralarında toplayıp iki grubu birleştiriyor; ilk eklenen parça Where olduğu için grupları AND ile bağladı. Kaynak koda baktığımda bağlacın ilk parçanın türüne göre seçildiğini gördüm. Benim önerim OrWhere'i hiç kullanmamak: OR gerekiyorsa onu tek bir Where içinde, parantezleriyle birlikte kendin yaz.
builder.Where("(author_id = @authorId or price > @minPrice)", new { authorId = 1, minPrice = 12.0 });
Ekosistem: Kim Hâlâ Ayakta?
Dapper bilerek küçük tutuluyor; CRUD yazmaktan sıkılanlar için etrafında bir paket ekosistemi büyüdü. Kasım 2026 itibarıyla NuGet'teki durumları şöyle.
Dapper.Contrib
Bir zamanlar "resmî" CRUD yardımcısıydı: [Table], [Key] attribute'larıyla işaretlediğin class'lar için Get, Insert, Update, Delete üretir. SQLite'ta hâlâ çalışıyor:
var id = conn.Insert(new Author { Name = "Isaac Asimov" });
Console.WriteLine(id);
var author = conn.Get<Author>(id);
author.Name = "Isaac Asimov (1920-1992)";
Console.WriteLine(conn.Update(author));
Console.WriteLine(conn.Get<Author>(id).Name);
Console.WriteLine(conn.Delete(author));
[Table("authors")]
public class Author
{
[Key]
public long Id { get; set; }
public string Name { get; set; } = "";
}
3
True
Isaac Asimov (1920-1992)
True
Ama son sürümü 2.0.78 ve Kasım 2020'den. Ayrı bir repoya taşındı, son kod commit'i Mayıs 2021'de; NuGet'te resmî olarak "deprecated" işaretli değil ama fiilen bakımsız. Gündelik bir kısıtı da var: property adını kolon adı olarak kullanır ve snake_case'i tanımaz. AuthorId property'li bir books class'ı eklemeye çalıştığımda:
Microsoft.Data.Sqlite.SqliteException: SQLite Error 1: 'table books has no column named AuthorId'.
Dapper.Rainbow
Dapper'ın ana reposunda duruyor ve onunla birlikte 2.1.89 olarak yayınlanıyor, ama koduna en son Şubat 2024'te dokunulmuş. Database<T> ve Table<T> ile tablo başına CRUD sağlar; kaynak koddaki insert sorgusu set nocount on ve scope_identity() kullandığı için pratikte SQL Server'a bağlı. Yeni bir projede kısaca bilmen yeterli.
Dommel
Aktif bakılan tek açık kaynak CRUD katmanı: 3.5.3 Haziran 2026'da çıktı, MIT lisanslı, net8.0/net9.0/net10.0 hedefliyor ve Dapper 2.1.72 ve üstüne bağlı. Connection tipine göre SQL üreten bir builder seçiyor ve LINQ ifadelerini where'e çeviriyor. 11. yazıda Dapper'ın çekirdeğinin görmezden geldiği [Column] attribute'unu Dommel tanıyor:
DommelMapper.LogReceived = message =>
{
if (message.StartsWith("Select<") || message.StartsWith("Insert<"))
Console.WriteLine(message);
};
var cheap = conn.Select<Book>(b => b.Price < 12);
foreach (var b in cheap)
Console.WriteLine($"{b.Id} | {b.Title} | {b.Price}");
var newId = conn.Insert(new Book { Title = "Foundation", AuthorId = 1, Price = 9.5 });
Console.WriteLine(newId);
[Table("books")]
public class Book
{
public long Id { get; set; }
public string Title { get; set; } = "";
[Column("author_id")]
public long AuthorId { get; set; }
public double Price { get; set; }
}
Select<Book>: select * from "books" where "books"."Price" < @p1
2 | Dune Messiah | 10
3 | The Left Hand of Darkness | 11
Insert<Book>: insert into "books" ("Title", "author_id", "Price") values (@Title, @AuthorId, @Price); select last_insert_rowid() id
5
Üretilen SQL'e bak: select *. Tablo kolon kazandıkça okunan veri de büyür; Dapper.AOT'un analyzer'ı bu kalıp için ayrıca uyarı veriyor (16. yazı).
Dapper Plus
ZZZ Projects'in ticari ürünü (Z.Dapper.Plus, son sürüm 9.3.6, Ekim 2026). Bulk insert, update, delete ve merge sağlar ve kendi lisans sözleşmesiyle gelir. Bilmen gereken bir bağlam: learndapper.com sitesini de aynı şirket işletiyor ve oradaki "Bulk Operations" sayfaları Dapper Plus'ı anlatıyor. Dapper'ın kendisinde bulk işlem yok; 15. yazıda ücretsiz alternatiflere bakacağız.
Hangisini Seçmeli?
İki seçenekli bir cevap vereyim. Birincisi: SQL'i kendin yaz, tekrar eden CRUD'u küçük bir repository ya da birkaç yardımcı method'la topla, dinamik filtreler için SqlBuilder kullan. Bağımlılık az, üretilen SQL tamamen senin kontrolünde; bedeli biraz daha fazla kod. İkincisi: Dommel gibi bakımı süren bir katman ekle. Basit tablolarda çok kod kazandırır; bedeli, SQL'in bir kısmının artık senin yazmadığın bir yerden gelmesi ve select * gibi tercihlerin sana dayatılması.
Dapper.Contrib'i yeni bir projeye eklemezdim. Bu kadar CRUD otomasyonu istiyorsan dürüst soru şu: EF Core mu lazım? Change tracking, migration'lar ve LINQ'in tamamı EF Core'da zaten var; EF Core serisinin 09. yazısı dinamik sorguların orada IQueryable üzerine parça eklenerek nasıl doğal biçimde kurulduğunu anlatıyor. Dapper'ın gücü SQL'e yakınlığı; her şeyi otomatikleştiren bir katman o gücü azaltır.
Sık Düşülen Tuzaklar
SqlBuilder'ın parçalarını parameter sanmak. Where("title like '%" + term + "%'") yazarsan SqlBuilder onu olduğu gibi SQL'e koyar. Değerler her zaman ikinci argümandaki anonymous object'ten gelmeli.
Aynı parameter adını iki parçada farklı değerlerle kullanmak. Bütün parçaların parameter'ları tek bir DynamicParameters'ta birleşir; Where("price >= @price", new { price = 11.0 }) ve Where("price <= @price", new { price = 12.0 }) yazdığımda iki koşul da 12.0 ile çalıştı ve sorgu sessizce boş döndü; sonra eklenen değer kazanıyor. Her parçaya kendine özgü bir ad ver.
İki CRUD paketini aynı projeye eklemek. Dapper.Contrib ile Dommel'i birlikte ekleyip conn.Insert(...) yazdığımda proje derlenmedi:
error CS0121: The call is ambiguous between the following methods or properties: 'Dapper.Contrib.Extensions.SqlMapperExtensions.Insert<T>(System.Data.IDbConnection, T, System.Data.IDbTransaction, int?)' and 'Dommel.DommelMapper.Insert<TEntity>(System.Data.IDbConnection, TEntity, System.Data.IDbTransaction?)'
İkisi de IDbConnection üzerine aynı adla extension method ekliyor. Birinden göç ederken bile ikisini aynı dosyada using etme.
Üretilen SQL'i hiç okumamak. SqlBuilder için RawSql'i, Dommel için LogReceived'ı geliştirme sırasında mutlaka yazdır. Dinamik SQL'in hatası, çoğu zaman yalnızca belirli bir filtre kombinasyonunda ortaya çıkar.
Küçük Bir Deneme
★☆☆ Açılıştaki örneğe bir authorName filtresi ekle. Filtre doluysa InnerJoin ve Where eklensin, boşsa join hiç yapılmasın. Üç kombinasyon için RawSql'i yazdır.
★★☆ SortColumns sözlüğünü asc/desc yönünü de destekleyecek şekilde genişlet: "price" ve "-price" anahtarlarıyla iki yönü ayır ve kullanıcının metnini yine hiçbir koşulda SQL'e koyma.
★★★ Aynı builder'a bağlı üç template kur: sayım, sayfa ve fiyat aralığını veren select min(price), max(price). Üçünü 09. yazıdaki QueryMultiple ile tek bir round trip'te çalıştır; parameter'ların üç template arasında nasıl paylaşıldığını kontrol et.
Dinamik SQL'i güvenle kurmayı gördük. Bir sonraki yazıda performansa dönüyoruz: Dapper'ın query cache'i ve CommandFlags.NoCache, GetRowParser ile satır başına tip seçmek ve Dapper'da olmayan bulk işlemler için SqlBulkCopy, Npgsql COPY ve SQLite'ta transaction içinde toplu insert gibi alternatifler.