Prosty soft delete z wykorzystaniem NHibernate

Soft delete jest jednym z podstawowych pojęć, które spotyka się w świecie crudowych aplikacji. Właściwie trudno wyobrazić sobie, aby jakakolwiek rzeczywista biznesowa aplikacja mogła istnieć bez możliwości oznaczania rekordów jako usunięte zamiast ich fizycznego kasowania z bazy. (Niektórzy twierdzą nawet, że niczego nie powinno się z bazy usuwać - tak, na wszelki wypadek.) Oczywiście tak podstawowej funkcji nie mogło zabraknąć w moim frameworku. W tym poście opisuję w jaki sposób zaimplementowałem mechanizm soft delete w Pizzy, co przy okazji pozwala na pokazanie elastyczności i rozszerzalności NHibernate, czyli tych cech tego ORMa, których nie uświadczymy w konkrencyjnych produktach koncernu na M.

Krótka uwaga o idei soft delete

Tak, wiem o tym, że niektórzy uważają soft delete za zło i twierdzą że powinno się to realizować przez pełne audytowanie rekordów przy każdej zmianie. Może i tak, mi osobiście mechanizm oparty na dodatkowej kolumnie jeszcze nigdy nie sprawiał problemów, zaś audyt odkładający kopie całych rekordów (wszystko jedno, czy w tej samej tabeli, innej tabeli czy nawet dodatkowej bazie) wpływa dość mocno na szybkość działania i rozmiar bazy. Nie chcę iść tą drogą w prostym frameworku do nieskomplikowanych aplikacji. Ale może kiedyś… kto wie.

Oznaczanie obiektów podlegających soft delete

Pierwsza sprawa - zapewne część danych będziemy chcieli kasować trwale, a część jedynie ukrywać za pomocą soft delete. Z tego powodu potrzebna jest możliwość oznaczania wybranych klas jako ukrywane. Jako, że i tak potrzebujemy właściwości opisującej, czy obiekt jest ukryty czy też nie, tu wybór jest prosty - interfejs. O taki:

1 namespace Pizza.Persistence
2 {
3     public interface ISoftDeletable
4     {
5         bool IsDeleted { get; set; }
6     }
7 }

Implementacja ukrywania

Druga sprawa - jak “kasować” takie obiekty? Możliwości jest kilka:

  1. Zdefiniowanie interfejsu rozszerzającego ISession o metodę np. SoftDelete, i oczywiście napisanie klasy go implementującej.
  2. Dodanie takiej metody w głównym serwisie aplikacji.
  3. Podpięcie sie pod event rzucany przez NHibernate przed usunięciem obiektu.

Pierwsza możliwość wymusi używanie na korzystającym z frameworka programiście jakiegoś dziwnego interfejsu zamiast dobrze znanego wszystkim ISession, a co gorsza wprowadzi dodatkowe dziedziczenie. W przypadku korzystania z soft delete, trzeba będzie pamiętać o korzystaniu z innej niż zwykła sesji NH. Dziedziczenie to jak wszyscy już chyba wiemy zło, którego trzeba unikać. Pomysł zatem odpada z punktu widzenia dobrej archiektury i czystego kodu.

Druga możliwość wygląda teoretycznie lepiej, nie wymaga dziedziczenia, ale wymaga pamiętania o tym, że jest. Po zmianie już istniejącej klasy na ukrywalną, trzeba będzie przejrzeć kod i upewnić się, że nigdzie na tej klasie nie jest wołana metoda Delete - w przeciwnym razie dane będą usuwane mimo, że tego nie chcemy. Jeśli zrobimy to nieuważnie, to doprowadzimy do pewnej niespójności albo co gorsza znikania danych, które jednak chcemy na wszelki wypadek zostawić. To podejście jest niebezpieczne i niewygodne, więc również odpada.

Trzecia możliwość - może zostać uznana za “magiczną”, ale:

  1. Skoro oznaczamy obiekt jako ISoftDeletable, to znaczy, że chcemy aby był ukrywany, a nie usuwany.
  2. Nie trzeba będzie o niczym pamiętać w przypadku zmiany obiektu z usuwalnego na ukrywalny, i z powrotem.
  3. Nie ma dodatkowych klas, o których trzeba pamiętać - mniej do nauczenia w ramach frameworka.
  4. Zadziała w każdym przypadku, nie tylko jeśli użytkownik będzie korzystał z serwisów crudowych dostarczanych przez Pizzę, ale także jeśli w swoim własnym serwisie będzie operował na ISession. Zawsze, jeśli zawoła ISession.Delete na obiekcie oznaczonym jako ISoftDeletable nie zostanie on usunięty lecz ukryty.

Jak to zrobić w NHibernate

Ponieważ NHibernate ma bardzo rozbudowany system zdarzeń, w celu zamiany usuwania obiektu na jego ukrywanie, wystarczy właściwie zaimplementować odpowiedni event listener. W tym przypadku jest to: IPreDeleteEventListener.

 1 public class SoftDeleteEventListener : IPreDeleteEventListener
 2 {
 3     public bool OnPreDelete(PreDeleteEvent preDeleteEvent)
 4     {
 5         var softDeletable = preDeleteEvent.Entity as ISoftDeletable;
 6         if (softDeletable != null)
 7         {
 8             var session = preDeleteEvent.Session.GetSession(EntityMode.Poco);
 9 
10             softDeletable.IsDeleted = true;
11             session.Update(softDeletable);
12             session.Flush();
13 
14             return true;
15         }
16 
17         return false;
18     }
19 }

Wielkiej filozofii tu nie ma. Pobieramy obiekt, który został wysłany do metody ISession.Delete. Jeśli ten obiekt implementuje ISoftDeletable, zostanie mu ustawiona flaga IsDeleted na true i na bieżącej sesji zostanie wywołąne Update oraz Flush. Na końcu ifa znajduje się return true informujący NHibernate, że sami obsłużyliśmy operację usuwania, więc ma nie robić tego sam.

Odczytywanie tylko nieusuniętych danych

Dobra, potrafimy już oznaczać rekordy jako usunięte. Teraz musimy rozwiązać kolejne zadanie - ponieważ istnieją one fizycznie w bazie, to po wykonaniu jakieś zapytanie, także takie rekordy zostaną usunięte. Nie jest to oczywiście pożądane zachowanie, trzeba je zatem jakoś odfiltrować. Można oczywiście rozbudowywać ISession albo bazowy serwis o nowe metody, no ale wiemy już, że nie chcemy tego robić. To, czego potrzebujemy w celu filtrowania w NHibernate dla zmyłki nazywa się filtrem i jest on po prostu klasą dziedzicząca z FilterDefinition. Wygląda ona tak:

 1 public class SoftDeletableFilter : FilterDefinition
 2 {
 3     public const string FilterName = "SoftDeletable";
 4     private readonly string filterPropertyName = ObjectHelper.GetPropertyName<ISoftDeletable>(x => x.IsDeleted);
 5 
 6     public SoftDeletableFilter()
 7     {
 8         this.WithName(FilterName).WithCondition(string.Format("{0} = 0", this.filterPropertyName));
 9     }
10 }

Podobne rozwiązanie dla Entity Frameworka nazywa się znacznie bardziej intuicyjnie: “conditional mapping”, dzięki temu nikt o tym nie słyszał i nikt nie używa. Chociaż to w sumie lepiej, bo skoro nikt nie używa, to nikt nie znajdzie błędów. A więcej błędów to coś, czego w EF zdecydowanie nie trzeba. Win-win w czystej postaci.

Konfiguracja

Na koniec trzeba jakoś połączyć wszystkie te elementy i sprawić, żeby działały, czyli trzeba je dodać do konfiguracji NHibernate. W Pizzy jest jedno źródło konfiguracji, używane zarówno przez kontener IoC na potrzeby frameworka jak i aplikację inicjującą bazę danych oraz testy integracyjne. Jest nim klasa NHConfigurationFactory. Tam, w metodzie BuildConfiguration (oprócz wielu innych rzeczy) odbywa się rejestracja filtru oraz listenera. Wygląda to tak:

 1 private static Configuration BuildConfiguration(string connectionString, AutoPersistenceModel autoPersistenceModel)
 2 {
 3     Action<Configuration> config = c =>
 4     {
 5         c.EventListeners.PreUpdateEventListeners = new IPreUpdateEventListener[] { new AuditingEventListener() };
 6         c.EventListeners.PreInsertEventListeners = new IPreInsertEventListener[] { new AuditingEventListener() };
 7         c.EventListeners.PreDeleteEventListeners = new IPreDeleteEventListener[] { new SoftDeleteEventListener() };
 8     };
 9 
10     IPersistenceConfigurer sqlConfiguration = MsSqlConfiguration.MsSql2012
11         .ConnectionString(connectionString)
12         .ShowSql();
13 
14     var fullModel = autoPersistenceModel
15         .Conventions.AddFromAssemblyOf<AutomappingConfiguration>()
16         .AddFilter<SoftDeletableFilter>();
17 
18     var configuration = Fluently.Configure()
19         .Database(sqlConfiguration)
20         .Mappings(m => m.AutoMappings.Add(fullModel))
21         .ExposeConfiguration(config)
22         .BuildConfiguration();
23 
24     return configuration;
25 }

I to wszystko. Soft delete działa, a kod wygląda ślicznie, wszystko jest oparte o delegację zadań do malutkich klas spełniających SRP. Nie ma żadnych dziwnych hierarchii rozbudowanych godobjectów, które wymusiłoby używanie np. Entity Frameworka.

Opublikowano: