Dapper Execute ile INSERT, UPDATE, DELETE: Etkilenen Satırlar ve Yeni Id
Execute ile INSERT, UPDATE ve DELETE çalıştırıp etkilenen satır sayısını okuyor, yeni id'yi RETURNING ve last_insert_rowid() ile alıyoruz; SQL Server OUTPUT ve PostgreSQL RETURNING farkına bakıp listeyle Execute'un neden bulk olmadığını ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar Dapper 2.1 ve .NET 10 içindir.
Önce Tahmin Et
Stanislaw Lem'in üç kitabını tek bir Execute çağrısıyla ekliyoruz. Parameter olarak tek bir object değil, üç element'li bir array veriyoruz:
var newBooks = new[]
{
new { AuthorId = 3, Title = "The Cyberiad", Price = 10.75m },
new { AuthorId = 3, Title = "Fiasco", Price = 12.00m },
new { AuthorId = 3, Title = "Eden", Price = 8.50m }
};
int affected = connection.Execute(
"INSERT INTO books (author_id, title, price) VALUES (@AuthorId, @Title, @Price)",
newBooks);
Console.WriteLine(affected);
İki soru. Ekranda hangi sayı yazar? Ve daha önemlisi, bu çağrı veritabanına kaç tane INSERT command'ı gönderir: üç satırlık tek bir command mı, yoksa üç ayrı command mı? İkinci sorunun cevabı, bu yöntemi büyük veri yüklemelerinde kullanıp kullanmayacağına karar verdirecek.
Bookstore Veritabanı
Kurulum ilk yazıdakiyle aynı: .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);
""");
}
}
Bu yazıda iki küçük record da kullanıyoruz:
public record NewBook(int AuthorId, string? Title, decimal Price);
public record CreatedOrder(long Id, string CreatedAt);
Bütün parçalar BookstoreDb.Reset(); sonrasında oluşturulan tek bir connection üzerinde, yazıdaki sırayla çalışıyor; sayılar bir önceki adımın bıraktığı veriye göre.
Execute ve Etkilenen Satır Sayısı
Okuma için Query ailesini kullanıyorduk. Sonuç döndürmeyen command'lar, yani INSERT, UPDATE ve DELETE için Execute var. Dönüş değeri bir int: command'ın etkilediği satır sayısı.
int inserted = connection.Execute(
"INSERT INTO books (author_id, title, price, stock) VALUES (@AuthorId, @Title, @Price, @Stock)",
new { AuthorId = 5, Title = "Stories of Your Life and Others", Price = 12.80m, Stock = 7 });
int updated = connection.Execute(
"UPDATE books SET price = price * 1.10 WHERE author_id = @AuthorId",
new { AuthorId = 1 });
int deleted = connection.Execute(
"DELETE FROM books WHERE title = @Title",
new { Title = "Eden" });
Console.WriteLine($"inserted {inserted}, updated {updated}, deleted {deleted}");
inserted 1, updated 3, deleted 1
Parameter'lar bir önceki yazıdaki gibi çalışıyor: değerler SQL'e gömülmüyor, anonymous object'ten ayrı parameter olarak gidiyor. UPDATE, Le Guin'in üç kitabının fiyatını yüzde on artırdı ve 3 döndü. Arka planda Dapper, ADO.NET'in ExecuteNonQuery method'unu çağırıyor ve onun döndürdüğü sayıyı sana veriyor; yani bu sayının anlamı provider'ın ne raporladığına bağlı. INSERT/UPDATE/DELETE için bu çoğu zaman gerçekten değişen satır sayısıdır; ama örneğin SQL Server'da SET NOCOUNT ON açıksa -1 alırsın.
Sıfır Satır Bir Hata Değildir
Execute'un en çok göz ardı edilen özelliği, hiçbir satır etkilenmediğinde exception fırlatmaması. Bir satış yaptığını düşün: stoğu bir azalt, ama sadece stok varsa.
int sold = connection.Execute(
"UPDATE books SET stock = stock - 1 WHERE id = @Id AND stock > 0",
new { Id = 3 });
if (sold == 0)
{
Console.WriteLine("Out of stock or no such book");
}
Out of stock or no such book
The Dispossessed'in stoğu 0; UPDATE hiçbir satırla eşleşmedi ve Execute sessizce 0 döndürdü. Command başarılı, ama iş yapılmadı. Bu yüzden "kaydı güncelle" ya da "kaydı sil" diyen her yerde dönüş değerine bak. Sıfır, "böyle bir kayıt yok" ya da "koşul artık geçerli değil" demektir ve bunu bir 404'e, bir "stok tükendi" mesajına ya da bir eşzamanlılık hatasına çevirmek senin işin.
Koşulu WHERE'e koyup sonucu satır sayısıyla kontrol etmek, aynı zamanda eşzamanlılığa karşı ucuz bir koruma. Önce stoğu okuyup sonra C# tarafında azaltsaydın, iki istek aynı anda aynı son kitabı satabilirdi. Tek bir UPDATE içinde stock > 0 kontrolü ise veritabanı tarafında atomik çalışır.
Yeni Eklenen Satırın Id'si
INSERT sonrası en sık ihtiyaç, veritabanının ürettiği id'yi geri almak. SQLite'ta iki yol var. Modern olanı, SQLite 3.35'ten beri desteklenen RETURNING; Microsoft.Data.Sqlite 10.0.14 ile gelen SQLite sürümü 3.53.3. Klasik olanı ise aynı command içinde last_insert_rowid() çağırmak. İki durumda da bir değer döndüğü için Execute yerine ExecuteScalar kullanıyoruz:
long asimovId = connection.ExecuteScalar<long>(
"INSERT INTO authors (name, country) VALUES (@Name, @Country) RETURNING id",
new { Name = "Isaac Asimov", Country = "USA" });
long clarkeId = connection.ExecuteScalar<long>(
"""
INSERT INTO authors (name, country) VALUES (@Name, @Country);
SELECT last_insert_rowid();
""",
new { Name = "Arthur C. Clarke", Country = "UK" });
Console.WriteLine($"{asimovId} | {clarkeId}");
6 | 7
last_insert_rowid()'yi INSERT ile aynı command'da çağırmak önemli. Bu fonksiyon connection'a bağlıdır; ayrı bir çağrıda sorarsan, arada Dapper'ın connection'ı kapatıp açması ve pooling yüzünden hangi değeri göreceğin belirsizleşir.
RETURNING birden fazla kolon da döndürebilir. Veritabanında üretilen bir zaman damgasını da almak istiyorsan sonucu doğrudan bir record'a eşle:
var order = connection.QuerySingle<CreatedOrder>(
"""
INSERT INTO orders (customer_email, created_at)
VALUES (@Email, datetime('now'))
RETURNING id, created_at AS CreatedAt
""",
new { Email = "ada@example.com" });
Console.WriteLine(order);
CreatedOrder { Id = 1, CreatedAt = 2026-11-03 22:09:56 }
Buradaki alias'ı unutma: CreatedOrder positional bir record ve ikinci yazıda gördüğümüz gibi constructor mapping'i kolon adlarının parametre adlarıyla uyuşmasını ister. AS CreatedAt olmadan Dapper (System.Int64 id, System.String created_at) imzalı bir constructor arayıp bulamadığını söyleyen bir InvalidOperationException fırlatıyor. datetime('now') de UTC zamanı döndürüyor; çıktıdaki saat bu yüzden yerel saatten farklı görünebilir.
Elimizde artık siparişin id'si var; sipariş kalemlerini eklerken onu kullanıyoruz:
int lines = connection.Execute(
"""
INSERT INTO order_items (order_id, book_id, quantity, unit_price)
VALUES (@OrderId, @BookId, @Quantity, @UnitPrice)
""",
new[]
{
new { OrderId = order.Id, BookId = 4, Quantity = 1, UnitPrice = 18.90m },
new { OrderId = order.Id, BookId = 6, Quantity = 2, UnitPrice = 9.99m }
});
Console.WriteLine(lines);
2
Bir siparişi ve kalemlerini böyle ayrı çağrılarla yazmak, ortada bir hata olursa yarım bir sipariş bırakabilir. Bunu bir transaction ile nasıl tek bir iş birimine çevireceğimizi 07'de göreceğiz.
SQL Server ve PostgreSQL'de
Id alma kalıbı her veritabanında biraz farklı. SQL Server'da INSERT'e bir OUTPUT clause'u eklersin:
INSERT INTO authors (name, country)
OUTPUT INSERTED.id
VALUES (@Name, @Country);
PostgreSQL'de ise SQLite'takiyle aynı RETURNING var:
INSERT INTO authors (name, country)
VALUES (@Name, @Country)
RETURNING id;
İki durumda da C# tarafı değişmez: ExecuteScalar ya da QuerySingle. Bu SQL'leri bu yazıda çalıştırmıyoruz; SQL Server ve PostgreSQL'i docker ile kuracağımız 12. ve 13. yazılarda gerçek bir sunucuda göreceğiz. SQL Server'da sık gördüğün SELECT SCOPE_IDENTITY() de çalışan bir alternatif, ama birden fazla kolon döndürebildiği için OUTPUT daha esnek.
Liste ile Execute: Döngü, Bulk Değil
Tahmin sorusuna dönelim. İlk sorunun cevabı kolay, ekranda 3 yazdı. İkincisinin cevabı ise "üç ayrı command". Dapper parameter olarak bir koleksiyon aldığında connection'ı bir kez açar, tek bir command hazırlar ve koleksiyondaki her element için parameter'ları yeniden doldurup ExecuteNonQuery'yi tekrar çağırır; döndürdüğü sayı da bu çağrıların toplamıdır. Bunu Dapper'ın kaynak kodunda açıkça görebilirsin, ama bir deneyle hissetmek daha öğretici. Ortadaki element'i bilerek bozalım:
const string insertBook =
"INSERT INTO books (author_id, title, price) VALUES (@AuthorId, @Title, @Price)";
var batch = new List<NewBook>
{
new(4, "The Restaurant at the End of the Universe", 9.50m),
new(4, null, 9.50m),
new(4, "Life, the Universe and Everything", 9.50m)
};
try
{
connection.Execute(insertBook, batch);
}
catch (SqliteException ex)
{
Console.WriteLine(ex.Message);
}
var adams = connection.Query<string>(
"SELECT title FROM books WHERE author_id = 4 ORDER BY id");
Console.WriteLine(string.Join(" | ", adams));
SQLite Error 19: 'NOT NULL constraint failed: books.title'.
The Hitchhiker's Guide to the Galaxy | The Restaurant at the End of the Universe
İkinci element NOT NULL kısıtına takıldı, ama birinci kitap çoktan eklenmişti ve orada kaldı. Üçüncüye hiç sıra gelmedi. Tek bir çok satırlı INSERT olsaydı, ya hepsi ya hiçbiri eklenirdi. Bu davranış, listeyle Execute'un bir döngüden ibaret olduğunu kanıtlıyor ve iki sonucu var.
Birincisi atomiklik: listenin tamamı ya yazılsın ya hiç yazılmasın istiyorsan çağrıyı bir transaction içine almalısın. İkincisi performans. Her element ayrı bir command ve transaction yoksa her command kendi küçük transaction'ında çalışır. SQLite'ta bu, her satır için diske ayrı bir yazma demek. Aynı bin satırı iki şekilde ekleyelim:
var drafts = Enumerable.Range(1, 1000)
.Select(i => new NewBook(2, $"Draft {i}", 1.00m))
.ToList();
var watch = Stopwatch.StartNew();
connection.Execute(insertBook, drafts);
Console.WriteLine($"Without a transaction: {watch.ElapsedMilliseconds} ms");
connection.Open();
using (var transaction = connection.BeginTransaction())
{
watch.Restart();
connection.Execute(insertBook, drafts, transaction);
transaction.Commit();
Console.WriteLine($"Inside one transaction: {watch.ElapsedMilliseconds} ms");
}
Without a transaction: 336 ms
Inside one transaction: 1 ms
Sayılar benim makinemde, dosya tabanlı bir SQLite veritabanında alındı; senin makinende farklı çıkacaktır ama oran benzer olur. Kod aynı, command sayısı aynı; fark sadece her command'ın ayrı ayrı mı, yoksa tek bir transaction içinde mi kalıcı hâle getirildiği. Transaction'ı ve transaction: parametresini 07'de ayrıntılı göreceğiz. Burada şunu akılda tut: listeyle Execute rahat bir kısayol, ama bir bulk insert değil. Ağ üzerinden konuşulan bir SQL Server'da her element ayrıca bir gidiş-dönüş demek. Gerçek bulk yollarını, yani SqlBulkCopy'yi, PostgreSQL'in COPY'sini ve SQLite'ta toplu ekleme tekniklerini 15. yazıda konuşacağız.
Sık Düşülen Tuzaklar
Birincisi WHERE'siz UPDATE ya da DELETE. Dapper seni korumaz; UPDATE books SET stock = 0 bütün tabloyu sıfırlar ve Execute mutlu mesut etkilenen satır sayısını döndürür. Dönüş değerini beklediğin sayıyla karşılaştırmak, burada da son savunma hattın.
İkincisi Execute'u sonuç döndüren bir command için kullanmak. connection.Execute("SELECT id FROM books") bu kurulumda exception fırlatmıyor, -1 döndürüyor; satırlar okunmadan atılıyor. Sonuç istiyorsan Query, tek değer istiyorsan ExecuteScalar kullan.
Üçüncüsü last_insert_rowid()'yi ayrı bir çağrıda sormak. Yukarıda gördüğün gibi aynı command içinde sor ya da daha iyisi RETURNING kullan.
Dördüncüsü listeyle Execute'u büyük veri için kullanmak. Yüz satır için sorun değil, yüz bin satır için ise önce transaction'ı, sonra gerçek bulk araçlarını düşün.
Küçük Bir Deneme
★☆☆ Ted Chiang'ın bütün kitaplarının stoğunu 5 artıran bir UPDATE yaz ve etkilenen satır sayısını yazdır. Sonra aynı UPDATE'i olmayan bir yazar id'siyle çalıştır.
★★☆ Bir kitabı id'siyle silen bool DeleteBook(int id) method'u yaz. Silme gerçekleştiyse true, kayıt yoksa false dönsün. Aynı id ile iki kez çağır.
★★★ "Liste ile Execute" örneğini bir transaction içine al: connection.Open(), BeginTransaction(), Execute'a transaction parametresi ver ve exception olursa Rollback() çağır. Bu kez Douglas Adams'ın kaç kitabı veritabanında kalıyor? Bir de Rollback()'i hiç çağırmadan dene: using bloğu bittiğinde ne oluyor?
Bu yazıdaki bütün çağrılar senkrondu. Gerçek bir web uygulamasında veritabanı beklenirken thread'i bloklamak istemezsin. Bir sonraki yazıda QueryAsync ve ExecuteAsync'e, CommandDefinition ile CancellationToken geçirmeye ve ASP.NET Core'da connection'ları doğru yönetmeye geçeceğiz. Seri burada bitmiyor; Dapper'ın yeni sürümleri çıktıkça yeni yazılarla devam edeceğiz.