Dziś słów kilka na temat mapowania relacyjnych baz danych przy użyciu NHibernate. Nigdy jakoś nie mogłem się przemóc do tego mappera, gdyż zawsze moja fascynacja tym rozwiązaniem pryskała na myśl o pisaniu własnoręcznie mapowania w pliku xml oraz pisania klas prezentujących mapowane obiekty. Co najmniej jedna z tych czynności powinna być zautomatyzowana (podług definicji DRY – Don’t Repeat Yourself) aby nie tworzyć dodatkowych – znacznych – problemów przy wprowadzaniu refaktoryzacji i modyfikacji. Jedną z metod rozwiązujących ten problem jest wykorzystanie narzędzia takiego jak Fluent NHibernate lub NHibernate.Mapping.Attributes. Z racji iż ostatnio dowiedziałem się o istnieniu tego drugiego (dzięki Czarkowi ;]) moje opory co do tego ORMa ostatecznie zniknęły i mogłem się spokojnie zabrać za testowanie nowego rozwiązania i zabawę w mapowanie baz danych z innych projektów.

image

Pierwszą rzeczą za jaką się wziąłem było zmapowanie prostych kilku tabel w bazie MySQL. Diagram przedstawiający tabele, które mapuję zamieszczony jest poniżej. Na potrzeby prezentacji wizualnej problemu dorzuciłem w modelu EER relację pomiędzy tymi tabelkami, jednak w praktyce relacja tam nie występuje (mimo iż z logicznego punktu widzenia powinna) z konkretnych powodów, o których za chwilę.

Baza MySQL umożliwia swobodny (lub prawie swobodny) wybór silnika używanego do obsługi konkretnych tabel – co jest zgoła innym podejściem niż to widoczne np. w bazie Sql Server. W każdym bądź razie tabela Tasks działa na standardowym silniku InnoDB (wspierającym transakcje, klucze, indeksy), a Tasks_History działa na silniku Archive, który nie wspiera co prawda praktycznie żadnych funkcjonalności, których oczekiwalibyśmy od porządnej bazy danych (ACID i inne), ale za to jest niesamowicie szybki, kompresuje dane w locie by zmniejszyć wielkość danych na dysku fizycznym, a ponadto zabezpiecza dane przed modyfikacją. Stąd też nazwa. Warto także wspomnieć, że na tabeli Tasks są założone 2 triggery – jeden na dodawanie i drugi na edycję rekordów. Obydwa wykonują generalnie tę samą czynność – w momencie dodania lub edycji wiersza dodają nowy rekord do tabeli historycznej z danymi, które zostały aktualnie wprowadzone.

Taka architektura (mimo iż bardzo prosta, jak na obecny etap) stwarza kilka problemów, które skutecznie uniemożliwiają stosowanie innych ORM-ów w tym miejscu (m.in. z powodu braku bazodanowej relacji pomiędzy tabelami). Rozwiązania tj. Entity Framework, Telerik OpenAccess, czy LiNQ to SQL (to już nawet z kilku więcej powodów) odpadają na starcie, ale nie NHibernate. W nim zmapowanie takiej architektury nie stwarza najmniejszych problemów, jest tylko kilka rzeczy, o których warto pamiętać i które warto dodać by usprawnić sobie nieco pracę.

Pierwsza jednak uwaga, to potencjalny błąd na jaki można się natknąć. Nie jest on trywialny (w szczególności w zrozumieniu o co chodzi) i wygląda tak:

XML validation error: The element 'class' in namespace 'urn:nhibernate-mapping-2.2' has invalid child element 'property' in namespace 'urn:nhibernate-mapping-2.2'. List of possible elements expected: 'meta, subselect, cache, synchronize, comment, tuplizer, id, composite-id'.  

lub w polskiej wersji:

XML validation error: Element element 'class' w obszarze nazw 'urn:nhibernate-mapping-2.2' ma nieprawidłowy element podrzędny element 'property' w obszarze nazw 'urn:nhibernate-mapping-2.2'. Lista oczekiwanych możliwych elementów: element 'meta, subselect, cache, synchronize, comment, tuplizer, id, composite-id' w obszarze nazw 'urn:nhibernate-mapping-2.2'.  

Na pierwszy rzut oka trudno stwierdzić, że chodzi o brak mapowania dla primary key w tabeli. Możemy bowiem oczekiwać, że skoro tabela historyczna nie posiada żadnych kluczy oraz indeksów (może mieć jeden tylko indeks - ja założyłem jedynie unikalność na polu revision), to nasze mapowanie też powinno być go pozbawione, jednak wystarczy poszukać w dokumentacji NHibernate by znaleźć poniższe zdanie: “Mapped classes must declare the primary key column of the database table.”. Wiedząc już tyle rozwiązanie jest całkiem proste – wystarczy zamiast atrybutu Property użyć atrybutu Id nad właściwością Revision.

Kolejna rzecz jakiej moglibyśmy oczekiwać od naszego mapowania to, to żeby wymuszało tryb tylko do odczytu na całej tabeli historycznej. Zapis zmian w jakimkolwiek rekordzie historycznym nie będzie miał miejsca tak czy siak, ale jeśli zamiast silnika Archive korzystalibyśmy z MyISAM (który również jest dobry do składowania danych tego typu) to warto byłoby zabezpieczyć się na tę okazję; oraz wymusić widoczność ograniczenia podczas pisania kodu, zamiast podczas testowania.

Tak naprawdę to rozwiązanie jest bardzo proste, tylko nie należy ulegać pokusie używania dodatkowych właściwości mapowania (trudno mi wskazać czemu, bo wedle dokumentacji powinny działać, ale nie do końca się tak dzieje – jakiś głębszy temat jest to zapewne). Poprawna i działająca wersja mapowania przy wykorzystaniu prywatnych setterów wygląda następująco:

[Class(0, Table = "tasks_history")]
public class TaskHistory  
{
   [Id(0, Name = "RevisionId", Column = "revision")] 
    public virtual int RevisionId { get; private set; }

    [Property(Column = "name")]
    public virtual string Name { get; private set; }

    [...]
}

To zaś, czemu nie powinniśmy ulegać to poniższa konstrukcja (która działa tylko według dokumentacji :)):

private int _revisionId;  
[Id(0, Name = "RevisionId", Column = "revision", Access = "nosetter")]
public virtual int RevisionId  
{
    get { return _revisionId; }
}

Po takim zamapowaniu mamy strukturę obiektową, która działa aż miło, a oprócz tego jest całkiem wydajna i łatwo dostosowywalna do nawet najbardziej zakręconych baz danych, gdzie używane są nawet więcej niż 2 różne silniki w najróżniejszych kombinacjach. I to jest zasadnicza potęga NHibernate ;]