Spis treści:

Kategoria:C#SQL Server


Prosty i wydajny ORM po naszemu

Budowa narzędzia typu O/RM ciąg dalszy

Jedzie kierowca ciężarówki i widzi zielonego ludzika. Pyta się ludzika:
- Czemu stoisz na ulicy?
- Jestem bardzo głodny i jestem pedałem.
Dał mu kanapkę i pojechał dalej. Spotyka czerwonego ludzika. Pyta się ludzika:
- Czemu stoisz na ulicy?
- Chce mi się pić i jestem pedałem.
Dał mu picie i pojechał dalej.
Widzi niebieskiego ludzika. Wkurzony wysiada i pyta:
- A ty pedale czego chcesz?
- Prawo jazdy i dowód rejestracyjny.

Jakiś czas temu pisałem o problemach z ogólnie dostępnymi narzędziami typu OR/M. Mówi się, że jak coś jest do wszystkiego, to jest do niczego i czasami taki moloch okazuje się kolosem na glinianych nogach. Problemy te zostały poruszone między innymi tutaj: Zrób to sam - prosty i wydajny OR/M dla ADO.NET. Opisałem tam sposób przepakowywania danych do obiektów, konwertery, dynamicznie kompilowany kod, konwertery anonimowe i pewne drobne rozwiązania optymalizacyjne. Zainteresowanych szczegółami odsyłam do wspomnianego artykułu. Ale, problem narzędzi typu OR/M nie kończy się na wykonywaniu zapytań i pobieraniu danych. Taki sam, żmudny, mało inteligentny kod pisze się podczas operacji wstawiania nowych rekordów. Należy napisać odpowiednią instrukcję INSERT, wskazać odpowiednie kolumny, przekazać wartości w postaci parametrów i zadbać o aktualizację wartości, które mogą być wyliczane i generowane przez bazę. Takie bazodanowe rusztowanie można zbudować na obiekcie samemu.

Eliminowanie powtarzalnego kodu

Zanim przejdziemy do rozwiązania problemu wstawiania rekordów przypomnę jeszcze raz, co jest w tym procesie najbardziej irytujące:

  • konieczność ręcznego napisania instrukcji INSERT,
  • konieczność wskazania wszystkich kolumn, do których wstawiamy dane,
  • konieczność zdefiniowania parametru dla każdej wstawianej wartości,
  • konieczność synchronizowania wartości generowanych przez bazę (między innymi Identity, wartości domyślne),
  • obowiązek przepisania danych z obiektów do odpowiednich parametrów.

Zaproponowane przeze mnie rozwiązanie adresowane jest po części do wszystkich wskazanych podpunktów, z mniejszą lub większą wagą. W mapowaniu obiektowo relacyjnym najważniejszy jest obiekt i od niego zacznę.

Konwencja nazewnicza uzupełniona atrybutami

Dość powszechną praktyką w mapowaniach obiektowo relacyjnych jest stosowanie takich samych nazw klas i tabel w bazie oraz właściwości i nazw kolumn. Pozwala to nie tylko uzyskiwać mapowanie bez żadnych dodatkowych konfiguracji, ale także z większą łatwością odnajdywać się po dwóch stronach barykady - bazy danych i kodu aplikacji. Nie zawsze jednak mamy taki komfort. Uznałem wobec tego, że:

  • mapowanie będzie dopasowywało nazwę klasy do tabeli i nazwę właściwości do kolumny,
  • jeżeli nazwy nie będą takie same, mogą być zdefiniowane przy pomocy atrybutów Table(nazwa) oraz Column(nazwa),
  • jeżeli kolumna ma być całkowicie pominięta w instrukcji INSERT, należy ją oznaczyć atrybutem Ignore.

Warto jeszcze wspomnieć o jednym niesłychanie ważnym atrybucie. Znaczny odsetek baz danych stosuje sztuczne klucze z opcją identity, czyli autoinkrementacji. Oznacza to, że identyfikator nowego rekordu nadawany jest przez bazę danych, nie zaś samą aplikację. Inna, mniej powszechna technika polega na uzupełnianiu jednej z kolumn wartością oznaczającą czas wstawienia rekordu do bazy. Najwygodniej użyć wtedy kolumny z domyślną wartością generowaną przez bazę danych. Unikamy wtedy problemów z różnicami w zegarach systemowych aplikacji klienckich. Problem w tym, że tę wartość musimy odczytać. Skąd bowiem mielibyśmy wiedzieć, co nam ta baza wstawiła? Problem jest podobny do tego z identity - też musimy to jakoś pobrać. Tu zastosuję nieco inne rozwiązanie niż to, które stosowane jest w innych narzędziach typu OR/M (LINQ2SQL, Entity Framework). Narzędzia te do pobrania identyfikatora ostatnio wstawionego rekordu korzystają z funkcji SCOPE_IDENTITY(), ja skorzystam z instrukcji OUTPUT. Pozostaje jeszcze jedna zagadka: skąd mechanizm ma wiedzieć, że kolumna jest wyliczana przez bazę danych? Tu znów z pomocą przyjdzie atrybut. Można to zrealizować oddzielnym atrybutem, np. Computed, ale postanowiłem użyć do tego właściwości już istniejącego atrybutu Column z właściwością IsComputed.

Przydatny zestaw atrybutów

Wyposażeni w wiedzę z poprzedniego akapitu popatrzmy na, raczej proste, definicje wymienionych atrybutów:

public class TableAttribute : Attribute
{
    public string Name { get; set; }
    public TableAttribute(string name)
    {
        this.Name = name;
    }
}

public class ColumnAttribute : Attribute
{
    public string Name { get; set; }
    public bool IsComputed { get; set; }

    public ColumnAttribute()
    {

    }

    public ColumnAttribute(string name)
    {
        this.Name = name;
    }
}

public class IgnoreAttribute : Attribute
{
}

Nie ma tu chyba co wyjaśniać. Są to tylko struktury pomocnicze; cała logika dzieje się w innym miejscu.

Pobieranie matadanych na temat obiektu

Aby utworzyć instrukcję INSERT należy przeanalizować obiekt. Co nam będzie potrzebne? Popatrzmy na poniższą instrukcję:

INSERT INTO DEV.Comments(Parent, Txt, Usr) VALUES (@p0, @p1, @p2)

Potrzebne nam będą:

  • Nazwa tabeli w bazie - będzie to nazwa klasy lub wartość z atrybutu,
  • Nazwy kolumn w bazie - będą to nazwy właściwości lub nazwy z atrybutów.

Oddzielnie będą też traktowane kolumny wstawiane przez użytkownika i kolumny wyliczane przez bazę. To również zostanie rozstrzygnięte dzięki atrybutom. Do całego algorytmu przyda się jeszcze jedna klasa reprezentująca poszczególne właściwości obiektu:

public class PropertyMap
{
    public PropertyInfo Property { get; set; }
    public string Name { get; set; }

    public override string ToString()
    {
        return Name;
    }
}

Klasa zawiera informacje o właściwości potrzebne do mechanizmu refleksji oraz fizyczną nazwę kolumny w bazie reprezentowaną przez tę właściwość. Tak wyposażeni możemy już przystąpić do implementacji klasy właściwej:

public class DbColumnList<T>
{
    public List<PropertyMap> Insert { get; private set; }
    public List<PropertyMap> Computed { get; private set; }
    public bool HasInsert { get { return Insert.Count > 0; } }
    public bool HasComputed { get { return Computed.Count > 0; } }
    public string TableName { get; private set; }

    public DbColumnList()
    {
        this.TableName = GetTableName();
        var properties = typeof(T).GetProperties();
        this.Insert = new List<PropertyMap>();
        this.Computed = new List<PropertyMap>();

        foreach (var property in properties)
        {
            var nameAttribute = property.GetCustomAttribute<ColumnAttribute>();
            var ignoreAttribute = property.GetCustomAttribute<IgnoreAttribute>();

            if (ignoreAttribute == null)
            {
                var obj = new PropertyMap()
                {
                    Name = nameAttribute != null && !string.IsNullOrEmpty(nameAttribute.Name) ? nameAttribute.Name : property.Name,
                    Property = property
                };
                if (nameAttribute != null && nameAttribute.IsComputed)
                {
                    Computed.Add(obj);
                }
                else
                {
                    Insert.Add(obj);
                }
            }
        }
    }

    private string GetTableName()
    {
        var tableAttribute = typeof(T).GetCustomAttribute<TableAttribute>();
        return tableAttribute != null ? tableAttribute.Name : typeof(T).Name;
    }

    public object[] GetParameters(T obj)
    {
        if (this.HasInsert)
        {
            object[] returnValue = new object[Insert.Count];
            for (int i = 0; i < Insert.Count; i++)
            {
                returnValue[i] = Insert[i].Property.GetValue(obj);
            }
            return returnValue;
        }
        else
        {
            return new object[0];
        }
    }
}

W skrócie wyjaśnię co się tutaj dzieje. Konstruktor klasy wykonuje kilka zadań. Po pierwsze, pobiera nazwę tabeli przy pomocy funkcji GetTableName. Jeżeli klasa ma atrybut, będzie on definiował nazwę tabeli. Jeżeli nie ma, zostanie użyta nazwa klasy. Dociekliwi dostrzegli zapewne, że nigdzie nie definiuje się schematu, w którym tabela w bazie danych się znajduje. Jeżeli nazwa będzie odpowiadała nazwie klasy, użyty zostanie domyślny schemat użytkownika. Jeżeli użyjemy atrybutu, schemat może być z łatwością przekazany.

Kolejne linijki konstruktora analizują poszczególne właściwości i przyporządkowują je do dwóch grup: kolumn zwykłych i kolumn wyliczanych przez bazę. Istnieje wprawdzie jeszcze jedna grupa, do której należą kolumny pomijane, ale można je spokojnie... pominąć.

Warto jeszcze wspomnieć o metodzie GetParameters. Przegląda ona listę kolumn wstawianych do bazy i pobiera dla nich rzeczywiste wartości pochodzące z obiektu. Obiektu, który ma być wstawiony do bazy. To tutaj znajduje się pierwsza faza magicznego mapowania obiektowo relacyjnego. Przekazywany w parametrze obiekt przekształcany jest na tablicę wartości pochodzących z odpowiednich właściwości tego obiektu. To te wartości będą parametrami @p0, @p1 i @p2 w pokazanym wcześniej wzorcu. Wydaje się, że to wszystko. Wiemy jaka jest tabela, jakie są kolumny, jakie są wartości i które z tych wartości będą wygenerowane i zwrócone przez bazę. Można przejść do samej operacji INSERT.

Generowanie i wykonywanie operacji INSERT

Czas wykorzystać zebrane informacje i uruchomić w końcu fizyczne zapytanie na bazie danych. Popatrzmy na pokazany poniżej listing:

public T Insert<T>(T obj)
{
    var columnList = new DbColumnList<T>();

    if (columnList.Insert.Count > 0 || columnList.Computed.Count > 0)
    {
        StringBuilder sb = new StringBuilder();
        sb.AppendFormat("INSERT {0}{1}\r\n",
            columnList.TableName,
            columnList.HasInsert ? string.Format("({0})", string.Join(", ", columnList.Insert.Select(a => a.Name))) : string.Empty);

        if (columnList.HasComputed)
        {
            sb.AppendFormat("OUTPUT {0}\r\n", string.Join(", ", columnList.Computed.Select(a => string.Format("INSERTED.{0}", a.Name))));
        }

        if (columnList.HasInsert)
        {
            sb.AppendFormat("VALUES ({0})", string.Join(", ", columnList.Insert.Select((a, i) => string.Format("@p{0}", i))));
        }
        else
        {
            sb.Append("DEFAULT VALUES");
        }

        string query = sb.ToString();
        var parameters = columnList.GetParameters(obj);

        using (var conn = new SqlConnection(this.ConnectionString))
        using (var cmd = new SqlCommand(query, conn))
        {
            conn.Open();
            for (int i = 0; i < parameters.Length; i++)
            {
                cmd.Parameters.AddWithValue(string.Format("p{0}", i), parameters[i]);
            }
            using (var reader = cmd.ExecuteReader())
            {
                if (reader.Read())
                {
                    for (int i=0; i<columnList.Computed.Count; i++)
                    {
                        columnList.Computed[i].Property.SetValue(obj, reader[i]);
                    }
                }
            }
        }
    }

    return obj;
}

Jeżeli do przetworzenia są jakieś kolumny, algorytm zaczyna budować zapytanie. Proces wydaje się prosty: sklejamy słowo INSERT z nazwą tabeli, a potem podajemy listę kolumn (lub nic, jeżeli żadnej kolumny nie wstawiamy a chcemy pobrać kolumny generowane przez bazę danych). W tym momencie znajdujemy się w miejscu, w którym powinna się znaleźć sekcja OUTPUT zapytania. Co to oznacza? To, że dla każdej kolumny wyliczanej, o ile takie są, pobieramy wygenerowaną wartość. Lista wartości poprzedzona jest zarezerwowanym na te potrzeby przez SQL Server słowem INSERTED. INSERTED można traktować jak wirtualną tabelę, która zawiera wszystkie wstawione przez zapytanie elementy. Ostatni etap polega na otwarciu połączenia, ustawieniu zapytania i przekazaniu wszystkich parametrów (metoda GetParameters).

Ostatnia faza to odczytanie rezultatów (będą to wyniki zwrócone przez OUTPUT. Pozwala to ustawić wartości zapisywanego obiektu i sprawić, że będzie zawierał dokładnie to, co baza. Taki obiekt, z już ustawionym kluczem sztucznym typu Identity lub ustawioną datą zapisania rekordu, może być zwrócony. Zapis rekordu i synchronizacja z bazą danych wykonywana jest za pomocą jednej instrukcji. Popatrzmy jeszcze, jak wstawiać rekordy za pomocą naszego własnego narzędzia OR/M.

Sposób użycia narzędzia

Wiadomo, że od strony aplikacji .NET będziemy korzystać z obiektów. Trzeba zatem taki obiekt stworzyć. Można oczywiście stworzyć dodatkowe narzędzie, które takie obiekty nam wygeneruje, ale można je też napisać samemu. Automat generujący takie obiekty też nie jest szczególnie trudny. Powiem więcej - można go zintegrować z Visual Studio i w zasadzie zapomnieć o bazie! Nie będę tego teraz opisywał, żeby za bardzo nie mieszać tematów i nie utrudniać zrozumienia przykładu.

Budowa obiektu zapisywanego w bazie

Obiekt w najprostszej postaci będzie zwykłą klasą z publicznymi właściwościami. Ja, żeby pokazać rozszerzone możliwości zbudowanego narzędzia, trochę ten obiekt skomplikuję. Pisząc skomplikuję mam na myśli ozdobienie go kilkoma atrybutami. Popatrzmy na listing:

[Table("DEV.Comments")]
public class Comment
{
    [Column(IsComputed=true)]
    public int ID { get; set; }

    public int Parent { get; set; }

    [Column("Txt")]
    public string Html { get; set; }

    [Column("Dat", IsComputed=true)]
    public DateTime Date { get; set; }

    [Column("Usr")]
    public string User { get; set; }
}

Widać, że przezwana jest tabelaDo tabeli dodany jest schemat DEV. Pozwala to na wykonywanie zapytań na tabelach innych niż te, które są umieszczone w domyślnym schemacie., przezwane są dwie kolumny. Kolumny ID oraz Dat (dostępna pod właściwością Date) oznaczone są jako kolumny generowane przez bazę. Gdybyśmy zastosowali jednolite nazewnictwo w obiekcie i w bazie danych, atrybutów byłoby znacznie mniej. Przykład to przykład - rządzi się swoimi prawami.

Test metody do zapisu

Powoli zmierzamy do szczęśliwego końca. Czas na pokazanie efektów naszej pracy. Żeby nie przedłużać popatrzmy od razu na zaprezentowany poniżej fragment kodu:

var toSave = new Comment()
{
    Parent = 1,
    Html = "Tekst testowy",
    User = "Ja"
};

var saved = new Database().Insert(toSave);

Prościej się chyba nie da. Tworzymy zwykły obiekt, uzupełniamy wymagane przez bazę danych pola i wykonujemy operację Insert. Funkcja musi się gdzieś znajdować, więc została umieszczona w klasie Database. Co interesujące i bardzo ważne, w obiekcie saved ustawione są wszystkie pola, również t wyliczane.

Zapytanie generowane przez pokazany kod

Sam efekt jest satysfakcjonujący. Puryści zechcą jednak podejrzeć rzeczywiste zapytanie wysłane do bazy. Popatrzmy na kod SQL wygenerowany dla pokazanej wcześniej instrukcji:

INSERT DEV.Comments(Parent, Txt, Usr)
OUTPUT INSERTED.ID, INSERTED.Dat
VALUES (@p0, @p1, @p2)

Instrukcja jest czysta i sformatowana dokładnie tak, jak ją tu wkleiłem. Nawet najbardziej restrykcyjny administrator baz danych nie powinien narzekać na czytelność kodu.

Możliwości rozwoju

Wiadomo, że pomiędzy pokazanym tutaj rozwiązaniem a narzędziami jest duża różnica. Po pierwsze, należałoby zadbać o właściwą obsługę wartości null. ADO (ADO.NET) istnieje od dawna, od czasów, w których nie było nawet typu ogólnego Nullable<T> i nie było łatwego sposobu na obsłużenie NULL. Twórcy ADO uznali, że NULL będzie reprezentowany przez referencję do obiektu DBNull.Value i tego się muszą trzymać. Trzeba pamiętać, że DBNull.Value to nie null i bezpośrednie, automatyczne konwersje nie istnieją. Trzeba to obsłużyć samemu. Dalsza możliwość rozbudowy to obsługa list, relacji, zapisywania od razu całych struktur, między innymi rekordu głównego z listą obiektów zależnych. Pojawia się wtedy problem odpowiedniej kolejności wykonywania zapytań oraz przypisywania pól, które są kluczami obcymi. Na ten dzień problem pozostanie nierozstrzygnięty.

Kategoria:C#SQL Server

, 2014-02-18

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
Dzieki za rozjasnienie zagadnienia upsert. wlasnie sie ucze programowania :).
Co się stanie gdy spróbuję wyszukać:
SELECT * FROM NV_Airport WHERE Code='SVO'
SELECT * FROM V_Airport WHERE Code=N'SVO'
(odwrotnie są te N-ki)
Będzie konwersja czy nie znajdzie żadnego rekordu?