Seit Entity Framework 6.1 können Entwickler mit CommandTreeInterceptoren SQL-Anweisungen abfangen und abändern. Im Gegensatz zu den CommandInterceptoren, die bereits mit 6.0 zur Verfügung standen, stellen CommandTreeInterceptoren die SQL-Anweisung nicht in Form eines Strings sondern in Form eines Syntax-Baumes dar. Dies vereinfacht das Abändern der Anweisung. Auf diese Weise lassen sich globale Filter definieren. Ein solcher globaler Filter könnte zum Beispiel festlegen, dass nur Datensätze mit einem bestimmten Status oder nur Datensätze eines bestimmten Mandanten geladen werden. Dazu müsste der Interceptor jeder Abfrage um eine entsprechende Einschränkung erweitern.
Auf der Teched 2014 hat Entity-Framework-Programm-Manager Rowan Miller ein Beispiel präsentiert, in dem er einen CommandTreeInterceptor zur Implementierung von Soft-Deletes nutzt. Hierunter versteht man Fälle, in denen Anwendungen obsolete Datensätze nicht tatsächlich löschen, sondern nur als gelöscht markieren. Den Quellcode dieses Beispiels findet man unter [1]. Da es sich bei Soft-Deletes um eine häufig anzutreffendes Muster im Bereich der Datenbankprogrammierung handelt und Entity Framework hierfür keine Boardmittel bietet, demonstriert dieser Abschnitt den Einsatz von CommandTreeInterceptoren anhand von Auszügen dieses Beispiels.
Um den Einsatz von Soft Delete für ausgewählte Entitäten zu aktivieren, sieht das besprochene Beispiel ein Attribut SoftDeleteAttribute vor (siehe nachfolgendes Listing). Damit werden die jeweiligen Entitäten annotiert. Über die Eigenschaft ColumnName gibt der Entwickler den Namen der Spalte, welche Auskunft darüber gibt, ob der Datensatz als gelöscht gilt, an. Standardmäßig wird hierfür der Name IsDeleted angenommen.
Die statische Hilfsmethode GetColumnName nimmt einen EdmType, welcher im Entity Data Model eine Entität repräsentiert, entgegen und liefert den zuvor erwähnten Spaltennamen für diese Entität retour. Sie geht davon aus, dass diese Information unter dem Schlüssel customannotation:SoftDeleteColumnName in den im Model für diesen Typ hinterlegten Metadaten zu finden ist. Damit dies auch der Fall ist, konfiguriert der Entwickler für den verwendeten DbContext innerhalb der Methode OnModelCreating die Konvention AttributeToTableAnnotationConvention:
var conv =
new AttributeToTableAnnotationConvention(
"SoftDeleteColumnName",
(type, attributes) => attributes.Single().ColumnName);
modelBuilder.Conventions.Add(conv);
Mit dieser Konvention legt er fest, dass Entity Framework die Eigenschaft ColumnName des Attributes SoftDeleteAttribute in die Metadaten der Entität aufnehmen soll. Als Schlüssel gibt er hierfür SoftDeleteColumnName an; der zuvor betrachtete Präfix customannotation wird von dieser Konvention standardmäßig vergeben.
public class SoftDeleteAttribute: Attribute
{
public SoftDeleteAttribute()
{
ColumnName = "IsDeleted";
}
public string ColumnName { get; set; }
public static string GetColumnName(EdmType type)
{
var prop = type
.MetadataProperties
.Where(p =>
p.Name.EndsWith("customannotation:SoftDeleteColumnName"))
.FirstOrDefault();
string columnName = (prop != null) ? (string) prop.Value : null;
return columnName;
}
}
Richtig spannend wird das betrachtete Beispiel, wenn der CommandTreeInterceptor ins Spiel kommt. Diesen findet man im nachfolgenden Listing. Er bietet eine Methode TreeCreated an. Diese führt Entity Framework aus, nachdem es den Syntax-Baum für eine SQL-Anweisung erstellt hat. Die betrachtete Implementierung prüft zunächst, ob TreeCreated für das Store Model ausgeführt wird und somit einen Syntax Baum, aus dem später direkt SQL abgeleitet wird, beinhaltet. Ist dem nicht so, wird die Methode abgebrochen.
Anschließend greift sie auf die Eigenschaft Result des von Entity Framework übergebenen Kontextes zu. Diese Eigenschaft beinhaltet den Syntax-Baum und ist vom abstrakten Typ DbCommandTree. Die hier tatsächlich verwendete Subklasse gibt Auskunft über die Art der zugrunde liegenden SQL-Anweisung. Handelt es sich um ein SELECT kommt die konkrete Subklasse DbQueryCommandTree zum Einsatz. Für den Aufruf einer Stored Procedure oder Stored Function verwendet Entity Framework hingegen das Derivat DbFunctionCommandTree und für DML-Anweisungen (INSERT, UPDATE, DELETE) die abstrakte Klasse DbModificationCommandTree, von der die konkreten Klassen DbInsertCommandTree, DbUpdateCommandTree und DbDeleteCommandTree ableiten. Die Namen sind dabei Programm.
Da das hier behandelte Beispiel jedes SELECT erweitern muss, sodass nur Datensätze, die nicht als gelöscht markiert wurden, geladen werden, prüft es, ob es sich beim Baum um einen DbQueryCommandTree handelt. Ist dem so, wendet es auf den Baum einen SoftDeleteQueryVisitor an, indem sie ihn an dessen Methode Accept übergibt. Dies geht konform mit dem für solche Aufgaben häufig eingesetzten Entwurfs-Muster Visitor (Besucher). Implementierungen dieses Musters durchlaufen die Knoten eines Baums und übergeben diese an eine Komponente, welche als Visitor bezeichnet wird. Der Visitor besucht demnach die einzelnen Knoten. Dieser Visitor kann nun entscheiden, ob er die Daten des erhaltenen Knotens auswertet bzw. den Knoten abändert. Im betrachteten Fall kümmert sich der verwendete Visitor, welcher weiter unten genauer beschrieben wird, um das Hinzufügen der besprochenen Einschränkung.
Das Ergebnis von Accept verpackt die betrachtete Implementierung in einem neuen DbQueryCommandTree. Diesen hinterlegt es in der Eigenschaft Result des Kontextes, was zur Folge hat, dass Entity Framework damit und nicht mit dem ursprünglichen Baum vorliebnimmt.
Die unter [1] zu findende Implementierung prüft auch, ob der an TreeCreated übergebene Syntax-Baum einem DELETE entspricht. In diesem Fall formt es dieses zu einem UPDATE, welches den Datensatz als gelöscht markiert, um.
class CustomCommandTreeInterceptor : IDbCommandTreeInterceptor
{
public void TreeCreated(
DbCommandTreeInterceptionContext interceptionContext)
{
if (interceptionContext.OriginalResult.DataSpace
!= DataSpace.SSpace) return;
var tree = interceptionContext.Result as DbQueryCommandTree;
if (tree != null)
{
var newQuery = tree.Query.Accept(new SoftDeleteQueryVisitor());
interceptionContext.Result = new DbQueryCommandTree(
tree.MetadataWorkspace,
tree.DataSpace,
newQuery);
}
var deleteCommand = interceptionContext.OriginalResult
as DbDeleteCommandTree;
if (deleteCommand != null)
{
var column = SoftDeleteAttribute.GetColumnName(
deleteCommand.Target.VariableType.EdmType);
if (column != null)
{
var setClause =
DbExpressionBuilder.SetClause(
DbExpressionBuilder.Property(
DbExpressionBuilder.Variable(
deleteCommand.Target.VariableType,
deleteCommand.Target.VariableName),
column),
DbExpression.FromBoolean(true));
var update = new DbUpdateCommandTree(
deleteCommand.MetadataWorkspace,
deleteCommand.DataSpace,
deleteCommand.Target,
deleteCommand.Predicate,
new List { setClause }.AsReadOnly(),
null);
interceptionContext.Result = update;
}
}
}
}
Die Umsetzung des SoftDeleteQueryVisitors findet sich im nachfolgenden Listing. Sie leitet von der Klasse DefaultExpressionVisitor, welche Entity Framework als Basis-Klasse für Visitor-Implementierungen anbietet, ab und überschreibt die Methode Visit. Da es von Visit zahlreiche Überladungen gibt, ist zu betonen, dass es sich hier um jene Überladung von Visit, welche eine DbScanExpression entgegennimmt, handelt. Diese Methode ruft Entity Framework immer dann auf, wenn der jeweils besuchte Knoten das Abfragen von Daten einer Entität repräsentiert.
Die Methode Visit ermittelt mit der zuvor besprochenen Methode GetColumnName die für die betroffene Entität hinterlegte Spalte. Ist dieser Wert null, geht Visit davon aus, dass für die betrachtete Entität keine Soft-Deletes zum Einsatz kommen sollen und delegiert lediglich an die geerbte Basis-Implementierung. Ansonsten erweitert Visit die DbScanExpression um einen Filter, aus dem hervor geht, dass nur Datensätze bei denen die festgelegte Spalte den Wert true hat, zu laden und liefert sie retour.
public class SoftDeleteQueryVisitor : DefaultExpressionVisitor
{
public override DbExpression Visit(DbScanExpression expression)
{
var columnName =
SoftDeleteAttribute.GetColumnName(expression.Target.ElementType);
if (columnName != null)
{
var binding = DbExpressionBuilder.Bind(expression);
return DbExpressionBuilder.Filter(
binding,
DbExpressionBuilder.NotEqual(
DbExpressionBuilder.Property(
DbExpressionBuilder.Variable(
binding.VariableType, binding.VariableName),
columnName),
DbExpression.FromBoolean(true)));
}
else
{
return base.Visit(expression);
}
}
}
Die hinter diesem Beispiel stehende Idee wurde mittlerweile auch von der Community aufgegriffen. Unter [2] findet man beispielsweise ein Projekt, welches das Definieren von globalen Filtern für Entity Framework erlaubt. Es verbirgt die Komplexität der benötigten Interceptoren und Visitatoren vor dem Entwickler, indem er die Möglichkeit bekommt, Filter mit ein paar Zeilen Code zu hinterlegen.
[1] https://github.com/rowanmiller/Demo-TechEd2014
[2] https://github.com/jbogard/EntityFramework.Filters