LINQ Coding Guidelines #6–Behandlung von optionalen Filterkriterien

12. Februar 2015

Der letzte Beitrag hat bereits Filterkriterien getrennt um die Komplexität der Ausdrücke zu reduzieren. Ich sehe aber auch oft unnötige Komplexität, insbesondere dann, wenn es um optionale Filterkriterien geht.

Empfehlung: Optionale Filterkriterien in LINQ-Ausdrücken sollten nur angewendet werden, wenn sie tatsächlich benötigt werden.

 

Dynamisch berechnete Filterkriterien – insbesondere solche, die nur unter bestimmten Voraussetzungen notwendig sind – sind ein typisch für zumindest zwei Szenarien:

  1. Bei Suchfunktionen, in denen optionale Filterkriterien ergänzt werden müssen.
  2. Wenn es um Anwendungsberechtigungen geht, die sich auf die ausgewählten Daten beziehen, etwa Rollen, Filialzugehörigkeit oder irgendwelche Nummernkreise.

Hier ein konkretes Beispiel für eine – arg verunglückte – Suchfunktion:

   1: public Collection<CashTransactionBE> SearchTransactions(CashTransactionSearchCriteriaBE searchCriteria, long accountId)

   2: {

   3:     using (EvolutionDBEntities entities = new EvolutionDBEntities())

   4:     {                

   5:         var condition = LinqHelper.True<Transaction>();

   6:         var conditionBalance = condition.And(t => (((searchCriteria.MinimumAmount == null) || (t.Amount >= searchCriteria.MinimumAmount)) &&

   7:                                                   ((searchCriteria.MaximumAmount == null) || (t.Amount <= searchCriteria.MaximumAmount))));

   8:         var conditionDate = conditionBalance.And(t => (((searchCriteria.StartDate == null) || (t.Date > searchCriteria.StartDate.Value.AddDays(-1))) &&

   9:                                                       ((searchCriteria.EndDate == null) || (t.Date < searchCriteria.EndDate.Value.AddDays(1)))));

  10:         string transactionType = GetTransactionType(searchCriteria.CashTransactionType);

  11:         var conditionTransactionType = conditionDate.And(t => ((searchCriteria.CashTransactionType == TransactionType.All) || (transactionType == t.Type)));

  12:  

  13:         IEnumerable<Transaction> result = entities.Transaction

  14:             .Where(t => t.AccountID == accountId)

  15:             .Where(conditionTransactionType.Compile());

  16:  

  17:         return result.Select(t => t.ToBE()).ToCollection();

  18:     }

  19: }

Das ist nun gleich doppelt schlecht:

  1. Es wurde versucht eine einzige, umfassende Bedingung zu formulieren. Optionale Filterwerte werden dabei immer zunächst auf Vorhandensein geprüft, um dann den Vergleich durchzuführen. In beiden Fällen – sowohl wenn das Kriterium vorhanden ist, als auch wenn nicht – ist das mit überflüssiger Arbeit verbunden.
  2. Der Versuch das über Expression Trees abzuhandeln ist leider schief gegangen. Spätestens der Aufruf von Compile() überführt diesen in einen Delegate, so dass der letzte Where-Aufruf gegen Enumerable geht, nicht gegen Queryable. Der Filter wird also im Speicher (per LINQ-to-Objects) angewendet, nicht effizient auf der Datenbank.

Über die Lesbarkeit will ich gar nicht reden…

 

Dass LINQ-Ausdrücke erst ausgewertet werden wenn Daten abgerufen werden (Deferred Execution) ist den meisten Entwicklern bewusst. Dass das in der Konsequenz bedeutet, dass ich den LINQ-Ausdruck nicht in einem Rutsch aufbauen muss, sondern in mehreren Schritten ergänzen kann, machen sich viele Entwickler aber nicht klar.

Wenn man sich diesen Umstand im obigen Beispiel zunutze macht kann man problemlos jeden Teilfilter nach Bedarf ergänzen. Solange es sich um Und-Verknüpfungen handelt muss man nur einen weiteren Where()-Aufruf anhängen:

   1: public Collection<CashTransactionBE> SearchTransactions(CashTransactionSearchCriteriaBE searchCriteria, long accountId)

   2: {

   3:     using (EvolutionDBEntities entities = new EvolutionDBEntities())

   4:     {

   5:         var result = entities.Transaction

   6:             .Where(t => t.AccountID == accountId);

   7:  

   8:         if (searchCriteria.MinimumAmount != null)

   9:             result = result.Where(t => t.Amount >= searchCriteria.MinimumAmount);

  10:         if (searchCriteria.MaximumAmount != null)

  11:             result = result.Where(t => t.Amount <= searchCriteria.MaximumAmount);

  12:         if (searchCriteria.StartDate != null)

  13:             result = result.Where(t => t.Date > searchCriteria.StartDate.Value.AddDays(-1));

  14:         if (searchCriteria.EndDate != null)

  15:             result = result.Where(t => t.Date < searchCriteria.EndDate.Value.AddDays(1));

  16:         if (searchCriteria.CashTransactionType != TransactionType.All)

  17:         {

  18:             string transactionType = GetTransactionType(searchCriteria.CashTransactionType);

  19:             result = result.Where(t => transactionType == t.Type);

  20:         }

  21:  

  22:         return result.Select(t => t.ToBE()).ToCollection();

  23:     }

  24: }

Der Code ist durchschaubarer, effizienter (weil genau die benötigten Filter gesetzt werden) und weil das Jonglieren mit Expression Trees wegfällt löst sich der Bug quasi von selbst. Was will man mehr?

 

Einschränkungen die sich aus Berechtigungen oder dem momentanen Kontext ergeben lassen sich ebenso einfach ergänzen.