Dapper

30. Juli 2024

Passend zum letzten Beitrag über EF Core, hier eine kurze Zusammenfassung zu Dapper.

Dapper ist ein leichtgewichtiges Micro-ORM, das sich primär auf das schnelle Laden von Daten aus der Datenbank und Mappen der Ergebnisse in Objekte konzentriert. Im Vergleich zu anderen ORMs wie bspw. EF Core müssen bei Dapper die SQL-Statements von Hand geschrieben werden. Dies hat den Vorteil, dass diese so angepasst werden können, wie sie tatsächlich benötigt werden und wie sie performant durchgeführt werden können. Der Nachteil dazu liegt auf der Hand: die SQL-Statements müssen von Hand geschrieben werden 🤪.

Da Dapper keine Datenbankspezifika nützt, ist es für alle Datenbanken einsetzbar, indem IDbConnection um einige Extension-Methoden erweitert wird.

Lesen von Daten

Zum Lesen von Datensätzen stehen einige Funktionen zur Verfügung (jeweils eine synchrone und eine asynchrone): Query<T>, QueryFirst<T>, QueryFirstOrDefault<T>, QuerySingle<T>, QuerySingleOrDefault<T>.

Als generischer Parameter kann entweder eine Klasse angegeben werden, in die die Ergebnisse deserialisiert werden oder nichts, wodurch das Ergebnis vom Typ „dynamic“ ist.

Hier die Klasse, die für die nachfolgenden Beispiele verwendet werden:

public class Person
{
    public int Id { get; set; }
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

    public List<Pet> Pets { get; set; } = new();
    public List<Address> Addresses { get; set; } = new();
}

public class Pet
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public int OwnerId { get; set; }
    public bool IsDeleted { get; set; }
}

public class Address
{
    public int Id { get; set; }
    public string? City { get; set; }
}

Als nächstes das Beispiel, das alle Datensätze der Tabelle „people“ ausliest, bei denen die Id größer 1 ist.

var result = await conn.QueryAsync<Person>(
    """
        select
            id,
            first_name as FirstName,
            last_name as LastName
        from ef.people
        where id > @Id
    """,
    new
    {
        Id = 1
    });

Parameter werden im Normalfall als anonymes Objekt angegeben. Es kann allerdings auch einfach ein Dictionary vom Typ Dictionary<string, object> übergeben werden.

Dapper mappt die Rückgabewerte automatisch aufgrund des Namens. Groß-/Kleinschreibung ist nicht relevant. Enthalten die Spalten allerdings Underscores, so müssen diese mittels Angabe des Alias ausgelesen werden. Diese Logik kann allerdings angepasst werden. Dafür siehe weiter unten.

Es ist auch möglich mehrere Abfragen in ein SQL-Statement zu verpacken und gesammelt auszulesen:

var result = await conn.QueryMultipleAsync(
    """
        select
            id,
            first_name as FirstName,
            last_name as LastName
        from ef.people
        where id > @Id;
         
        select
            id,
            name,
            owner_id as OwnerId
        from ef.pets
        where owner_id > @Id;
    """,
    new
    {
        Id = 1
    });

var people = await result.ReadAsync<Person>();
var pets = await result.ReadAsync<Pet>();

Wichtig hierbei ist, dass die Read<T> in der Reihenfolge ausgeführt wird, wie sie im SQL-Statement angegeben wurde.

Schreiben von Daten

Das Schreiben (Insert, Update, Delete) ist ziemlich straightforward. Wichtig zu berücksichtigen ist, dass Dapper standardmäßig auch bei der Angabe von mehreren Objekten für jedes Objekt ein einzelnes Statement an die Datenbank sendet, ohne Verwendung einer Transaktion – außer diese wird manuell angegeben. Durch dieses Verhalten ist Dapper vor allem beim Schreiben von vielen Datensätzen nicht sonderlich performant. EF Core benötigt hier weniger Roundtrips zur Datenbank und ist daher entsprechend schneller.

var result = await conn.ExecuteAsync(
    """
        insert into ef.pets (name, owner_id, is_deleted)
        values (@Name, @OwnerId, @IsDeleted)
    """,
    Enumerable.Range(0, 1000).Select(c =>
    new
    {
        Name = $"Fiffi {c}",
        OwnerId = people[1].Id,
        IsDeleted = false
    }));

Anstatt wie in diesem Beispiel kann natürlich auch einfach nur ein Objekt angegeben werden.

Wird mit einer Identity-Spalte gearbeitet und von dieser soll die erzeugte ID retourniert werden, dann kann dies wie folgt gemacht werden:

var newId = await conn.QuerySingleAsync<int>(
    """
        insert into ef.pets (name, owner_id, is_deleted)
        values (@Name, @OwnerId, @IsDeleted);
        select scope_identity();
    """,
    new
    {
        Name = "Fiffi",
        OwnerId = people.First().Id,
        IsDeleted = false
    });

Lesen von Beziehungen

Eine sehr interessante Variante ist das Lesen von Beziehungen in einem SQL-Statement, wenn auch dies etwas befremdlich wirkt 😉.

var dic = new Dictionary<int, Person>();
await conn.QueryAsync<Person, Pet?, Address?, Person>(
    """
        select
            p.id,
            p.first_name as FirstName,
            p.last_name as LastName,
            pe.id as IdPet,
            pe.id,
            pe.name,
            pe.owner_id as OwnerId,
            a.Id as IdAddress,
            a.Id,
            a.city
        from ef.people p
        left join ef.pets pe on p.id = pe.owner_id
        left join ef.addresses a on p.id = a.id 
        where p.id > @Id
    """,
    (person, pet, address) =>
    {
        if (!dic.TryGetValue(person.Id, out var entry))
        {
            dic.Add(person.Id, person);
            entry = person;
        }
            
        if (pet != null && pet.Id > 0 && !entry.Pets.Any(p => p.Id == pet.Id))
            entry.Pets.Add(pet);
            
        if (address != null && address.Id > 0 && !entry.Addresses.Any(a => a.Id == address.Id))
            entry.Addresses.Add(address);

        return entry;
    },
    new
    {
        Id = 1
    },
    splitOn: "IdPet,IdAddress");

var result = dic.Values;

OK, das Beispiel und die Implementierung sind nicht 100 % praxistauglich, aber es zeigt, wie es funktioniert.

Wichtig sind zwei Komponenten: der „splitOn“-Parameter sowie die Lambda-Expression. „splitOn“ definiert die Eigenschaft, ab welcher die nächste Entität beginnt. Da in meinem Falle alle Tabellen eine Id-Spalte haben, habe ich die Id zusätzlich mit einem Alias herausgeladen, damit der Split funktioniert. Pro Ergebniszeile aus dem SQL wird die Lambda-Expression 1x aufgerufen. Der Code setzt die Objekte dann entsprechend zusammen.

Verwendung von Record

Bei der Verwendung von „record“ (anstatt class) ist Dapper sensibel. Hierbei muss das Select-Statement mit den Spalten genau so definiert werden wie der Primärkonstruktor des Records. Ansonsten wirft Dapper eine Exception, in der der benötigte Konstruktor angezeigt wird.

Type-Mapping anpassen

Das Type-Mapping, wie zuvor beschrieben, kann auch angepasst werden.

SqlMapper.SetTypeMap(
    typeof(Person),
    new CustomPropertyTypeMap(
        typeof(Person),
        (type, columnName) =>
        {
            columnName = columnName.Replace("_", string.Empty);
              
            return type
                .GetProperties()
                .First(c => 
                    c.Name.Equals(columnName, StringComparison.InvariantCultureIgnoreCase));
        }));

In diesem Beispiel werden die Underscores entfernt und es wird nach einer PropertyInfo passend zum Namen gesucht.

Eine weitere, mehr allgemeine Logik ist es, den Func „SqlMapper.TypeMapProvider“ zu überschreiben. Dieser gibt pro Type ein Objekt von ITypeMap zurück.