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

Dapper Type Handler'ları ve Özel Eşleme: Guid, DateTimeOffset ve CustomPropertyTypeMap

Mert Özen 6 Kas 2026 11 dk 2 görüntülenme
Dapper Type Handler'ları ve Özel Eşleme: Guid, DateTimeOffset ve CustomPropertyTypeMap

SQLite'ta Guid, DateTimeOffset ve TimeSpan okurken Dapper neden patlar? SqlMapper.TypeHandler<T> ile tercüman yazıyor, SetValue'nun neden çağrılmadığını kaynak koddan görüyor, DateOnly/TimeOnly'nin 2.1.86 hikâyesini ve CustomPropertyTypeMap ile kolon eşlemesini ele alıyoruz.

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

Önce Tahmin Et

Bookstore veritabanımıza bu kez kitabın kimliğini Guid, yayın anını DateTimeOffset, ortalama okuma süresini TimeSpan ve ilk baskı tarihini DateOnly olarak tutan bir books tablosu açıyoruz. Bir satır ekleyip aynı satırı geri okuyoruz:

using System.ComponentModel.DataAnnotations.Schema;
using System.Data;
using System.Globalization;
using System.Reflection;
using Dapper;
using Microsoft.Data.Sqlite;

DefaultTypeMap.MatchNamesWithUnderscores = true;

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();

conn.Execute("""
    create table books (
        id text primary key,
        title text not null,
        published_at text not null,
        read_time text not null,
        release_date text not null
    )
    """);

var id = Guid.Parse("3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13");

conn.Execute(
    "insert into books values (@Id, @Title, @PublishedAt, @ReadTime, @ReleaseDate)",
    new
    {
        Id = id,
        Title = "Dune",
        PublishedAt = new DateTimeOffset(2024, 3, 1, 9, 30, 0, TimeSpan.FromHours(3)),
        ReadTime = TimeSpan.FromMinutes(95),
        ReleaseDate = new DateOnly(1965, 8, 1)
    });

var book = conn.QuerySingle<Book>("select * from books where id = @id", new { id });
Console.WriteLine(book.Title);

public class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; } = "";
    public DateTimeOffset PublishedAt { get; set; }
    public TimeSpan ReadTime { get; set; }
    public DateOnly ReleaseDate { get; set; }
}

Üç aday var: insert patlar, where id = @id hiçbir satır bulamaz ya da her şey yolunda gider ve ekranda Dune yazar. Tahminini yap, sonra çıktıya bak:

Unhandled exception. System.Data.DataException: Error parsing column 0 (id=3F2B8C1E-7D4A-4E9B-9C61-2A5F0E8D7B13 - String)
 ---> System.InvalidCastException: Invalid cast from 'System.String' to 'System.Guid'.

Dördüncü seçenek kazandı. insert sorunsuz çalıştı, where satırı buldu, ama Dapper satırı Book object'ine dökerken ilk kolonda takıldı. Yazmak kolay, okumak zor. Bu yazı o asimetriyi ve onu kapatan aracı, yani type handler'ları anlatıyor.

SQLite'ın Dört Kutusu

Satırın veritabanında nasıl durduğuna bakalım. typeof() SQLite'ın her değer için tuttuğu gerçek storage class'ını söyler:

var row = conn.QuerySingle("""
    select id, typeof(id) as id_type, published_at, read_time, release_date
    from books
    """);
Console.WriteLine(row);
{DapperRow, id = '3F2B8C1E-7D4A-4E9B-9C61-2A5F0E8D7B13', id_type = 'text', published_at = '2024-03-01 09:30:00+03:00', read_time = '01:35:00', release_date = '1965-08-01'}

SQLite'ın elinde sadece dört kutu var: tamsayı, ondalıklı sayı, metin ve blob. Microsoft.Data.Sqlite parameter'ları bu kutulara yerleştirirken akıllı davranıyor: Guid'i büyük harfli metne, DateTimeOffset'i offset'li bir tarih metnine, TimeSpan'i 01:35:00'a çeviriyor. Yazma yönünde provider işi zaten yapıyor.

Okuma yönünde ise Dapper reader'ın indexer'ını kullanır ve provider oradan yalnızca long, double, string ya da byte[] döndürür. Dapper string'i Guid property'sine koymak için Convert.ChangeType'a başvurur; Guid, DateTimeOffset ve TimeSpan için böyle bir dönüşüm yolu yoktur ve sonuç yukarıdaki exception'dır. Microsoft Learn'deki "Dapper limitations" sayfası bu üç tipi adıyla sayar. ReleaseDate ise listede yok; onun neden çalıştığına birazdan geleceğiz.

TypeHandler<T>: İki Yönlü Bir Tercüman

SqlMapper.TypeHandler<T> iki abstract method'u olan küçük bir class: Parse veritabanından gelen değeri T'ye çevirir, SetValue ise T'yi bir IDbDataParameter'a yerleştirir. Bir tercüman gibi düşün: biri gelen mektubu okur, diğeri giden mektubu yazar. SQLite'ta giden mektubu provider zaten doğru yazdığı için yalnızca okuyan tarafı yazmamız yeterli:

public abstract class SqliteTypeHandler<T> : SqlMapper.TypeHandler<T>
{
    // parameter'ı Microsoft.Data.Sqlite zaten çeviriyor
    public override void SetValue(IDbDataParameter parameter, T? value) => parameter.Value = value;
}

public sealed class GuidHandler : SqliteTypeHandler<Guid>
{
    public override Guid Parse(object value) => Guid.Parse((string)value);
}

public sealed class DateTimeOffsetHandler : SqliteTypeHandler<DateTimeOffset>
{
    public override DateTimeOffset Parse(object value) =>
        DateTimeOffset.Parse((string)value, CultureInfo.InvariantCulture);
}

public sealed class TimeSpanHandler : SqliteTypeHandler<TimeSpan>
{
    public override TimeSpan Parse(object value) =>
        TimeSpan.Parse((string)value, CultureInfo.InvariantCulture);
}

Bu desen Microsoft'un kendi örneğindekiyle aynı; ben yalnızca CultureInfo.InvariantCulture ekledim, çünkü sunucunun bölge ayarı tr-TR olduğunda tarih parse etmek sürprizlere açık. Handler'ları uygulama açılırken bir kez kaydediyoruz:

SqlMapper.AddTypeHandler(new GuidHandler());
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new TimeSpanHandler());

var book = conn.QuerySingle<Book>("select * from books where id = @id", new { id });
Console.WriteLine($"{book.Id} | {book.PublishedAt:O} | {book.ReadTime} | {book.ReleaseDate:yyyy-MM-dd}");
3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13 | 2024-03-01T09:30:00.0000000+03:00 | 01:35:00 | 1965-08-01

Nullable tipler için ayrı handler gerekmez. AddTypeHandler Guid için kayıt yaparken Guid?'yi de aynı handler'a bağlar ve kolon NULL geldiğinde Parse hiç çağrılmaz; property doğrudan null olur:

SqlMapper.AddTypeHandler(new GuidHandler());

conn.Execute("create table reviews (id integer primary key, book_id text not null, reviewer_id text)");
conn.Execute("insert into reviews values (1, @BookId, null)", new { BookId = id });

var review = conn.QuerySingle<Review>("select * from reviews");
Console.WriteLine($"{review.BookId} | {review.ReviewerId?.ToString() ?? "null"}");

public class Review
{
    public long Id { get; set; }
    public Guid BookId { get; set; }
    public Guid? ReviewerId { get; set; }
}
3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13 | null

SetValue Neden Hiç Çağrılmadı?

"Madem tercüman iki yönlü, Guid'leri küçük harfle yazdırayım" dediğini düşün. SetValue'yu dolduran bir handler yazıyoruz ve çağrıldığını görebilmek için içine bir satır log koyuyoruz:

public sealed class LowerGuidHandler : SqlMapper.TypeHandler<Guid>
{
    public override Guid Parse(object value) => Guid.Parse((string)value);

    public override void SetValue(IDbDataParameter parameter, Guid value)
    {
        Console.WriteLine("SetValue called");
        parameter.DbType = DbType.String;
        parameter.Value = value.ToString();
    }
}
conn.Execute("create table tags (id text primary key, name text not null)");
SqlMapper.AddTypeHandler(new LowerGuidHandler());

conn.Execute("insert into tags values (@Id, 'sci-fi')", new { Id = id });
Console.WriteLine(conn.ExecuteScalar<string>("select id from tags"));
3F2B8C1E-7D4A-4E9B-9C61-2A5F0E8D7B13

Ne log satırı var ne de küçük harf. Sebep Dapper'ın kaynak kodunda, parameter tipini belirleyen LookupDbType method'unda: Dapper önce kendi yerleşik tip tablosuna bakıyor, handler'lara ancak orada bulamazsa geçiyor. Guid, DateTimeOffset ve TimeSpan o tabloda kayıtlı olduğu için okuma tarafında handler devreye giriyor, yazma tarafında ise sessizce atlanıyor.

Tabloyu boşaltırsan SetValue çalışmaya başlar, ama bu kez başka bir delik açılır:

conn.Execute("create table tags (id text primary key, name text not null)");
SqlMapper.RemoveTypeMap(typeof(Guid));
SqlMapper.AddTypeHandler(new LowerGuidHandler());

conn.Execute("insert into tags values (@Id, 'sci-fi')", new { Id = id });
Console.WriteLine(conn.ExecuteScalar<string>("select id from tags"));

var hits = conn.ExecuteScalar<long>(
    "select count(*) from tags where id in @ids", new { ids = new[] { id } });
Console.WriteLine(hits);
SetValue called
3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13
0

Satır küçük harfle yazıldı ama in @ids sorgusu onu bulamadı. Liste genişletmesi her element'i parameter'a ham hâliyle koyuyor, handler'ı kullanmıyor; provider da Guid'i yine büyük harfle gönderiyor. SQLite'ta metin karşılaştırması büyük-küçük harfe duyarlı olduğu için sonuç sıfır. Ders şu: SQLite'ta yazma formatını provider'a bırak, handler'ı okuma için kullan. Formatı kendin belirlemek zorundaysan, verinin geçtiği bütün yolları (tekil parameter, liste, ham SQL) aynı kurala bağladığından emin ol.

İkinci Yol: Provider'ın Kendi Okuyucusu

Dapper 2.0.138'den beri AddTypeMap'in üçüncü parametresi olan bir overload'ı var. useGetFieldValue: true dediğinde Dapper değeri indexer yerine provider'ın GetFieldValue<T>() method'uyla okur. Microsoft.Data.Sqlite bu method'da metinden Guid, DateTimeOffset ve TimeSpan üretmeyi biliyor:

SqlMapper.AddTypeMap(typeof(Guid), DbType.Guid, useGetFieldValue: true);
SqlMapper.AddTypeMap(typeof(DateTimeOffset), DbType.DateTimeOffset, useGetFieldValue: true);
SqlMapper.AddTypeMap(typeof(TimeSpan), DbType.Time, useGetFieldValue: true);

var book = conn.QuerySingle<Book>("select * from books where id = @id", new { id });
Console.WriteLine($"{book.Id} | {book.PublishedAt:O} | {book.ReadTime}");
3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13 | 2024-03-01T09:30:00.0000000+03:00 | 01:35:00

İki yolun farkı net. Handler yolu provider'dan bağımsızdır: Parse içinde istediğin formatı çözersin, eski verideki tuhaflıkları da orada tolere edersin. GetFieldValue yolu ise sıfır class ile çalışır ama tamamen provider'ın o tip için ne bildiğine bağlıdır; provider değiştiğinde davranış da değişebilir. Bookstore'da ben handler yolunu tercih ediyorum, çünkü kuralı kodda görünür kılıyor.

DateOnly ve TimeOnly: Uzun Bir Hikâyenin Sonu

İlk örnekte ReleaseDate neden hiç sorun çıkarmadı? Çünkü Dapper 2.1.86 itibarıyla DateOnly ve TimeOnly'yi tanıyor ve bu tipleri provider'ın GetFieldValue<T>()'süyle okuyor. Hikâyesi biraz çalkantılı: destek ilk kez 2.1.37'de geldi, bozuk çıktı, 2.1.42'de geri alındı ve 2.1.37–2.1.44 arası paketler NuGet'ten liste dışı bırakıldı. Düzeltilmiş hâli Eylül 2026'da 2.1.86 ile döndü. O tarihten önceki bir sürümdeysen bu iki tip için de handler yazman gerekiyordu.

conn.Execute("create table events (id integer primary key, day text not null, starts_at text not null)");
conn.Execute(
    "insert into events values (1, @Day, @StartsAt)",
    new { Day = new DateOnly(2026, 11, 6), StartsAt = new TimeOnly(19, 30) });

Console.WriteLine(conn.QuerySingle("select day, starts_at from events"));

var ev = conn.QuerySingle<BookEvent>("select * from events");
Console.WriteLine($"{ev.Day:yyyy-MM-dd} {ev.StartsAt:HH:mm}");

public class BookEvent
{
    public long Id { get; set; }
    public DateOnly Day { get; set; }
    public TimeOnly StartsAt { get; set; }
}
{DapperRow, day = '2026-11-06', starts_at = '19:30:00'}
2026-11-06 19:30

SQL Server ve PostgreSQL tarafında da işler yoluna girdi: Dapper, provider date kolonu için DateTime döndürse bile onu DateOnly'ye, time için TimeSpan döndürse bile TimeOnly'ye çeviriyor.

Kolon Adı Uymuyorsa: CustomPropertyTypeMap

Type handler bir değerin nasıl çevrileceğini söyler; hangi kolonun hangi property'ye gideceğini söylemez. O iş type map'in. EF Core'dan gelen birinin ilk refleksi [Column] attribute'u olur, ama Dapper'ın çekirdeği o attribute'u tanımaz:

conn.Execute("create table editions (id integer primary key, book_title text, pub_year integer)");
conn.Execute("insert into editions values (1, 'Dune', 1965)");

var before = conn.QuerySingle<Edition>("select * from editions");
Console.WriteLine($"{before.Id} | '{before.Title}' | {before.PublishedYear}");

public class Edition
{
    public long Id { get; set; }

    [Column("book_title")]
    public string Title { get; set; } = "";

    [Column("pub_year")]
    public int PublishedYear { get; set; }
}
1 | '' | 0

Hata yok, sadece boş değerler var; sessiz olduğu için de en tehlikeli türden. MatchNamesWithUnderscores açık olsa bile pub_year ancak PubYear'a gider. Çözüm, mapping'i kendin tarif etmek: CustomPropertyTypeMap, her kolon adı için hangi property'nin seçileceğini soran bir fonksiyon alır. Attribute'u okumak tamamen senin kararın:

SqlMapper.SetTypeMap(typeof(Edition), new CustomPropertyTypeMap(
    typeof(Edition),
    (type, columnName) => type.GetProperties().FirstOrDefault(p =>
        string.Equals(p.GetCustomAttribute<ColumnAttribute>()?.Name, columnName, StringComparison.OrdinalIgnoreCase)
        || string.Equals(p.Name, columnName, StringComparison.OrdinalIgnoreCase))!));

var after = conn.QuerySingle<Edition>("select * from editions");
Console.WriteLine($"{after.Id} | '{after.Title}' | {after.PublishedYear}");
1 | 'Dune' | 1965

Fonksiyon null döndürdüğünde o kolon atlanır. Bir kısıtı bilmen gerek: CustomPropertyTypeMap yalnızca parametresiz constructor'la çalışır. Aynı mapping'i bir record'a uygularsan:

SqlMapper.SetTypeMap(typeof(EditionRecord), new CustomPropertyTypeMap(
    typeof(EditionRecord),
    (type, columnName) => type.GetProperties().FirstOrDefault(p =>
        string.Equals(p.Name, columnName, StringComparison.OrdinalIgnoreCase))!));

var edition = conn.QuerySingle<EditionRecord>(
    "select id, book_title as title, pub_year as published_year from editions");

public record EditionRecord(long Id, string Title, int PublishedYear);
Unhandled exception. System.InvalidOperationException: A parameterless default constructor or one matching signature (System.Int64 id, System.String title, System.Int64 published_year) is required for EditionRecord materialization

Record'larla çalışıyorsan en temiz yol SQL'de alias vermek ya da 02. yazıdaki MatchNamesWithUnderscores ve constructor mapping'ine yaslanmak. EF Core'daki per-property value converter ve kolon konfigürasyonuyla kıyaslarsan, Dapper'da hem handler'lar hem type map'ler tip başına ve süreç genelinde geçerli; EF Core serisinin 20. yazısındaki value conversion ile yan yana okumak farkı iyi gösterir.

Sık Düşülen Tuzaklar

Handler'ı ilk sorgudan sonra kaydetmek. Dapper her sorgu için ürettiği deserializer'ı cache'ler ve AddTypeHandler bu cache'i temizlemez:

Try(() => conn.QuerySingle<Book>("select * from books"));

SqlMapper.AddTypeHandler(new GuidHandler());
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new TimeSpanHandler());
Try(() => conn.QuerySingle<Book>("select * from books"));

SqlMapper.PurgeQueryCache();
Console.WriteLine(conn.QuerySingle<Book>("select * from books").Id);

static void Try(Action action)
{
    try { action(); }
    catch (Exception ex) { Console.WriteLine(ex.Message); }
}
Error parsing column 0 (id=3F2B8C1E-7D4A-4E9B-9C61-2A5F0E8D7B13 - String)
Error parsing column 0 (id=3F2B8C1E-7D4A-4E9B-9C61-2A5F0E8D7B13 - String)
3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13

İlk iki çağrı da aynı hatayı verdi; handler'lar kayıtlı olduğu hâlde ikinci çağrı eski deserializer'ı kullandı. Kayıtları Program.cs'in en başına, ilk sorgudan önceye koy. PurgeQueryCache bir kurtarma aracıdır, tasarım değil.

Enum'lar için handler yazıp parameter tarafında kullanılmasını beklemek. Dapper enum'ları varsayılan olarak alttaki sayısal tiple gönderir; handler'ın SetValue'sunu devreye almak için 2.1.79'da gelen Settings.PreferTypeHandlersForEnums'u açman gerekir:

conn.Execute("create table formats (id integer primary key, format text not null)");
SqlMapper.AddTypeHandler(new BookFormatHandler());

conn.Execute("insert into formats values (1, @Format)", new { Format = BookFormat.Paperback });

SqlMapper.Settings.PreferTypeHandlersForEnums = true;
conn.Execute("insert into formats values (2, @Format)", new { Format = BookFormat.Paperback });

foreach (var r in conn.Query("select id, format from formats"))
    Console.WriteLine(r);

public enum BookFormat { Hardcover, Paperback, Ebook }

public sealed class BookFormatHandler : SqlMapper.TypeHandler<BookFormat>
{
    public override BookFormat Parse(object value) =>
        Enum.Parse<BookFormat>((string)value, ignoreCase: true);

    public override void SetValue(IDbDataParameter parameter, BookFormat value)
    {
        parameter.DbType = DbType.String;
        parameter.Value = value.ToString().ToLowerInvariant();
    }
}
{DapperRow, id = '1', format = '1'}
{DapperRow, id = '2', format = 'paperback'}

Kayıtların global olduğunu unutmak. AddTypeHandler, SetTypeMap ve Settings süreç genelinde static state'tir. Paralel çalışan testler birbirinin kaydını ezebilir; iki farklı veritabanına bağlanan bir uygulamada da aynı Guid handler'ı iki provider için birden çalışır. Son olarak, Dapper.AOT bu runtime kayıtlarını görmez; handler'ları attribute ile bildirmen gerekir, 16. yazıda bunu göreceğiz.

Küçük Bir Deneme

★☆☆ books tablosuna price adında bir text kolonu ekle ve fiyatı "12.50" gibi yaz. decimal Price property'si için bir SqliteTypeHandler<decimal> yazmadan önce sorgunun ne yaptığını gözle, sonra handler'ı ekleyip farkı karşılaştır.

★★☆ LowerGuidHandler örneğini RemoveTypeMap ile çalıştır ve in @ids sorgusunun sıfır dönmesini düzelt. İki yolu dene: listeyi string[] olarak küçük harfle göndermek ve SQL'de lower(id) kullanmak. Hangisi index'i kullanmaya devam eder, explain query plan ile bak.

★★★ Kendi [DbColumn] attribute'unu yaz ve uygulama açılışında assembly'deki bütün tipleri tarayıp attribute taşıyan her tip için CustomPropertyTypeMap kaydeden bir yardımcı oluştur. Attribute'u olmayan property'lerde varsayılan isim mapping'ine geri düşmeyi unutma.

Handler'lar ve type map'ler, Dapper'ın SQLite'ın sınırlı tip dünyasıyla nasıl uzlaştığını gösterdi. Bir sonraki yazıda SQLite'tan bir süreliğine ayrılıp SQL Server'a geçiyoruz: stored procedure'lar, output ve return value parameter'ları ve 2.1.11'de gelen otomatik stored procedure algılamasının kurduğu tuzak.