Wprowadzenie
W poprzednim wpisie pokazałem Ci, w jaki sposób można dodać wsparcie dla Temporal Table w Entity Framework. Zaproponowane rozwiązanie nie jest idealne i ma swoje problemy. W dzisiejszym wpisie będę chciał Ci pokazać, jak rozwiązać część problemów z wykorzystaniem interceptorów. Umożliwią one modyfikowanie zapytań, które są wykonywane w serwerze bazy danych. Dzięki temu możemy obchodzić niektóre problemy w pracy z Entity Framework.
Rozbudowanie przykładu
W tym wpisie rozbudujemy przykład z wpisu poprzedniego, dlatego jeśli go nie czytałeś (Temporal Table w Entity Framework), to odsyłam Cię do niego. Wielokrotnie będę nawiązywał właśnie do tego wpisu.
W przykładzie dodamy klasę Order, którą następnie połączymy z klasą BaseProduct relacją wiele do wielu. W prawdziwej aplikacji mielibyśmy dodatkową klasę (np. OrderItem), w której znajdowałyby się dodatkowe informacje. Tutaj chodzi tylko o przykład, bez zbytniego rozbudowania.
Relacja ta pokaże nam kilka problemów z użyciem podejścia Table per Concrete Type, które rozwiążemy za pomocą interceptorów.
Po zmianach model danych w testowej aplikacji wygląda tak:
public class Order : BaseModel | |
{ | |
public string Number { get; set; } | |
public virtual ICollection<BaseProduct> Products { get; set; } | |
} |
public abstract class BaseProduct : BaseModel | |
{ | |
public string Name { get; set; } | |
public int CategoryId { get; set; } | |
public virtual Category Category { get; set; } | |
public string Description { get; set; } | |
public DateTime ValidFrom { get; set; } | |
public DateTime ValidTo { get; set; } | |
public virtual ICollection<Order> Orders { get; set; } | |
} |
Klasa Order zawiera właściwość dla numeru zamówienia oraz listę powiązanych produktów. W klasie BaseProduct pojawiła się właściwość dla listy zamówień.
Na podstawie zmian wygenerowałem nową migrację:
public partial class AddOrder : DbMigration | |
{ | |
public override void Up() | |
{ | |
CreateTable( | |
"dbo.Orders", | |
c => new | |
{ | |
Id = c.Int(nullable: false, identity: true), | |
Number = c.String(), | |
IsActive = c.Boolean(nullable: false), | |
}) | |
.PrimaryKey(t => t.Id); | |
CreateTable( | |
"dbo.OrderBaseProducts", | |
c => new | |
{ | |
Order_Id = c.Int(nullable: false), | |
BaseProduct_Id = c.Int(nullable: false), | |
//BaseProduct_ValidFrom = c.DateTime(nullable: false), | |
//BaseProduct_ValidTo = c.DateTime(nullable: false), | |
}) | |
//.PrimaryKey(t => new { t.Order_Id, t.BaseProduct_Id, t.BaseProduct_ValidFrom, t.BaseProduct_ValidTo }) | |
.PrimaryKey(t => new { t.Order_Id, t.BaseProduct_Id }) | |
.ForeignKey("dbo.Orders", t => t.Order_Id, cascadeDelete: true) | |
.Index(t => t.Order_Id); | |
} | |
public override void Down() | |
{ | |
DropForeignKey("dbo.OrderBaseProducts", "Order_Id", "dbo.Orders"); | |
DropIndex("dbo.OrderBaseProducts", new[] { "Order_Id" }); | |
DropTable("dbo.OrderBaseProducts"); | |
DropTable("dbo.Orders"); | |
} | |
} |
Podobnie jak wcześniej, należy trochę zmodyfikować migrację. W przypadku relacji wiele do wielu Entity Framework generuje w bazie dodatkową tabelę (w przykładzie jest to OrderBaseProducts), która zawiera kolumny dla kluczy głównych z obu powiązanych tabel. W związku z tym, że oszukujemy Entity Framework co do klucza głównego dla klasy BaseProduct, to w tym miejscu musimy usunąć kolumny powiązane z ValidFrom oraz ValidTo.
Robimy to w dwóch miejscach: w definicji kolumn dodatkowej tabeli oraz w definicji klucza głównego tej tabeli. W migracji zakomentowany kod zawiera to, co wygenerował Entity Framework.
Do klasy Program dodałem dwie nowe metody testowe. Pierwsza metoda tworzy nowe zamówienia z wszystkimi produktami, które znajdują się w bazie. Natomiast druga usuwa z zamówienia jeden produkt:
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
int productId = 0; | |
productId = AddAndUpdateWithHistory(); | |
int orderId = AddProductsToOrder(); | |
RemoveProductFromOrder(orderId, productId); | |
} | |
private static int AddProductsToOrder() | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var products = db.Products.OfType<Product>(); | |
var order = new Order() | |
{ | |
Number = "order number", | |
Products = products.Cast<BaseProduct>().ToList() | |
}; | |
db.Orders.Add(order); | |
db.SaveChanges(); | |
return order.Id; | |
} | |
} | |
private static void RemoveProductFromOrder(int orderId, int productId) | |
{ | |
using (DataContext db = new DataContext()) | |
{ | |
var order = db.Orders.FirstOrDefault(o => o.Id == orderId); | |
var product = order.Products.FirstOrDefault(p => p.Id == productId); | |
order.Products.Remove(product); | |
db.SaveChanges(); | |
} | |
} | |
} |
Problem z dodaniem zamówienia
W tym momencie, gdy uruchomimy aplikację, dostaniemy wyjątek podczas dodawania nowego zamówienia. Problem polega na tym, że Entity Framework próbuje dodać do tabeli łączącej wartości z kolumn ValidFrom oraz ValidTo. Widać to w SQL, który wykonuje się w serwerze bazy:
INSERT [dbo].[OrderBaseProducts] | |
([Order_Id], | |
[BaseProduct_Id], | |
[BaseProduct_ValidFrom], | |
[BaseProduct_ValidTo]) | |
VALUES (3 /* @0 - [Order_Id] */, | |
1 /* @1 - [BaseProduct_Id] */, | |
'2019-03-10T06:14:18' /* @2 - [BaseProduct_ValidFrom] */, | |
'9999-12-31T23:59:59' /* @3 - [BaseProduct_ValidTo] */) |
Poprawny SQL, który chcemy wykonać na bazie wygląda tak:
INSERT [dbo].[OrderBaseProducts] | |
([Order_Id], | |
[BaseProduct_Id]) | |
VALUES (3 /* @0 - [Order_Id] */, | |
1 /* @1 - [BaseProduct_Id] */) |
Potrzebujemy czegoś, co umożliwi nam zmodyfikowanie komendy i usunięcie z niej zbędnych kolumn. Tym czymś będzie interceptor.
Interceptory w Entity Framework
Kilka tygodni temu poruszałem temat interceptorów. Wpis dotyczył używania interceptorów w Autofac, ale tutaj idea jest bardzo podobna. Chodzi o to, żeby przygotować fragment kodu, który wykona się podczas tworzenia zapytania przez Entity Framework. Kod ten będzie usuwał zbędne kolumny, w związku z tym, że klucz główny w klasie BaseProduct jest inny niż w bazie danych.
Entity Framework udostępnia kilka różnych typów interceptorów. Dwa najpopularniejsze to IDbCommandInterceptor oraz IDbCommandTreeInterceptor. Pierwszego możemy użyć przed wykonaniem komendy w bazie danych lub po nim (mamy już gotowego SQL). Drugi natomiast jest wykonywany podczas budowania drzewa komendy jeszcze przed tym, jak provider wygeneruje zapytanie dla danego silnika bazy danych. My skorzystamy z tego drugiego typu.
Interfejs IDbCommandTreeInterceptor definiuje jedną metodę TreeCreated, do której otrzymujemy kontekst, a nim zbudowane drzewo komendy (właściwość Result kontekstu), które możemy zmodyfikować.
Implementacja interceptora
Najlepiej od razu przejść do implementacji interceptora, który rozwiązuje problem z dodaniem zamówienia:
internal class TemporalTableCommandTreeInterceptor : IDbCommandTreeInterceptor | |
{ | |
private static readonly List<string> _namesToIgnore = new List<string> { "ValidFrom", "ValidTo" }; | |
public void TreeCreated(DbCommandTreeInterceptionContext interceptionContext) | |
{ | |
if (interceptionContext.OriginalResult.DataSpace == DataSpace.SSpace) | |
{ | |
var insertCommand = interceptionContext.Result as DbInsertCommandTree; | |
if (insertCommand != null) | |
{ | |
var newCommand = HandleInsertCommand(insertCommand); | |
interceptionContext.Result = newCommand; | |
} | |
var updateCommand = interceptionContext.Result as DbUpdateCommandTree; | |
if (updateCommand != null) | |
{ | |
var newCommand = HandleUpdateCommand(updateCommand); | |
interceptionContext.Result = newCommand; | |
} | |
} | |
} | |
private static DbUpdateCommandTree HandleUpdateCommand(DbUpdateCommandTree updateCommand) | |
{ | |
var newSetClauses = GenerateSetClauses(updateCommand.SetClauses); | |
var newCommand = new DbUpdateCommandTree( | |
updateCommand.MetadataWorkspace, | |
updateCommand.DataSpace, | |
updateCommand.Target, | |
updateCommand.Predicate, | |
newSetClauses, | |
updateCommand.Returning); | |
return newCommand; | |
} | |
private static DbInsertCommandTree HandleInsertCommand(DbInsertCommandTree insertCommand) | |
{ | |
var newSetClauses = GenerateSetClauses(insertCommand.SetClauses); | |
var newCommand = new DbInsertCommandTree( | |
insertCommand.MetadataWorkspace, | |
insertCommand.DataSpace, | |
insertCommand.Target, | |
newSetClauses, | |
insertCommand.Returning); | |
return newCommand; | |
} | |
private static ReadOnlyCollection<DbModificationClause> GenerateSetClauses(IList<DbModificationClause> modificationClauses) | |
{ | |
var props = new List<DbModificationClause>(modificationClauses); | |
props = props.Where(_ => IgnoreProperty(_) == false).ToList(); | |
var newSetClauses = new ReadOnlyCollection<DbModificationClause>(props); | |
return newSetClauses; | |
} | |
private static bool IgnoreProperty(DbModificationClause clause) | |
{ | |
string propertyName = (((clause as DbSetClause)?.Property as DbPropertyExpression)?.Property as EdmProperty) | |
?.Name; | |
if (propertyName == null) | |
{ | |
return false; | |
} | |
return _namesToIgnore.Any(n => propertyName.Contains(n)); | |
} | |
} |
Na początku definiujemy listę nazw właściwości do ignorowania. W naszym przypadku będzie to ValidFrom oraz ValidTo.
Następnie w samej metodzie TreeCreated sprawdzamy przede wszystkim DataSpace. Entity Framework wykorzystuje kilka modeli. Po pierwsze mamy Conceptual Model (CSpace), który określa model klas w aplikacji. Drugim rodzajem modelu jest Storage Model (SSpace) określający model bazy danych. Do tego mamy jeszcze mapowanie jednego modelu na drugi (na ogół zapisane w klasie EntityTypeConfiguration).
Będziemy chcieli modyfikować zapytanie na poziomie Storage Model, ponieważ w tym modelu mamy między innymi tę dodatkową tabelę łączącą. Dlatego na początku metody TreeCreated znajduje się sprawdzenie typu modelu.
W naszym przypadku chcemy usunąć dodatkowe kolumny w operacji Insert oraz Update, dlatego sprawdzamy aktualny typ komendy i gdy się zgadza, wywołujemy odpowiednią metodę pomocniczą.
Drzewa komend są immutable, czyli nie możemy ich zmodyfikować, a jedynie możemy utworzyć nowe drzewa, które nie będą zawierały tych dodatkowych kolumn. Właściwość SetClauses w przypadku obu komend zawiera listę kolumn do dodania lub aktualizacji. Dlatego interceptor filtruje tę listę, usuwa z niej zbędne kolumny, a następnie tworzy nowe drzewo. Do sprawdzenia nazw kolumn wykorzystujemy metodę Contains, ponieważ kolumny bardzo często będą zawierały przedrostek, tak jak w przykładzie: BaseProduct_ValidFrom oraz BaseProduct_ValidTo.
Gdy mamy już gotowy interceptor, należy go jeszcze zarejestrować w Entity Framework. Osobiście robię to w statycznym konstruktorze klasy DataContext (klasa zwiera więcej kodu niż sam listing):
public class DataContext : DbContext | |
{ | |
static DataContext() | |
{ | |
DbInterception.Add(new TemporalTableCommandTreeInterceptor()); | |
} | |
} |
Pobieranie danych z bazy
Po rozwiązaniu jednego problemu pojawia się kolejny. W drugiej testowej metodzie pobieramy jeden produkt z zamówienia i próbujemy go usunąć. W tym przypadku dostajemy wyjątek podczas pobierania produktu powiązanego z zamówieniem. Entity Framework generuje takie zapytanie:
SELECT CASE | |
WHEN ([UnionAll1].[C1] = 1) THEN '1X0X' | |
ELSE '1X1X' | |
END AS [C1], | |
[UnionAll1].[Id] AS [C2], | |
[UnionAll1].[ValidFrom] AS [C3], | |
[UnionAll1].[ValidTo] AS [C4], | |
[UnionAll1].[Name] AS [C5], | |
[UnionAll1].[CategoryId] AS [C6], | |
[UnionAll1].[Description] AS [C7], | |
[UnionAll1].[IsActive] AS [C8] | |
FROM [dbo].[OrderBaseProducts] AS [Extent1] | |
INNER JOIN (SELECT [Extent2].[Id] AS [Id], | |
[Extent2].[ValidFrom] AS [ValidFrom], | |
[Extent2].[ValidTo] AS [ValidTo], | |
[Extent2].[Name] AS [Name], | |
[Extent2].[CategoryId] AS [CategoryId], | |
[Extent2].[Description] AS [Description], | |
[Extent2].[IsActive] AS [IsActive], | |
cast(0 as bit) AS [C1] | |
FROM [dbo].[ProductsHistory] AS [Extent2] | |
UNION ALL | |
SELECT [Extent3].[Id] AS [Id], | |
[Extent3].[ValidFrom] AS [ValidFrom], | |
[Extent3].[ValidTo] AS [ValidTo], | |
[Extent3].[Name] AS [Name], | |
[Extent3].[CategoryId] AS [CategoryId], | |
[Extent3].[Description] AS [Description], | |
[Extent3].[IsActive] AS [IsActive], | |
cast(1 as bit) AS [C1] | |
FROM [dbo].[Products] AS [Extent3]) AS [UnionAll1] | |
ON ([Extent1].[BaseProduct_Id] = [UnionAll1].[Id]) | |
AND ([Extent1].[BaseProduct_ValidFrom] = [UnionAll1].[ValidFrom]) | |
AND ([Extent1].[BaseProduct_ValidTo] = [UnionAll1].[ValidTo]) | |
WHERE [Extent1].[Order_Id] = 4 /* @EntityKeyValue1 - [Order_Id] */ |
Problematyczny jest warunek instrukcji Inner Join (druga oraz trzecia linijka od końca). Znajduje się tam sprawdzanie tych dodatkowych kolumn z datami.
Chcemy teraz pozbyć się dodatkowych sprawdzeń. Zrobimy to również w interceptorze:
internal class TemporalTableCommandTreeInterceptor : IDbCommandTreeInterceptor | |
{ | |
private static readonly List<string> _namesToIgnore = new List<string> { "ValidFrom", "ValidTo" }; | |
public void TreeCreated(DbCommandTreeInterceptionContext interceptionContext) | |
{ | |
if (interceptionContext.OriginalResult.DataSpace == DataSpace.SSpace) | |
{ | |
var queryCommand = interceptionContext.Result as DbQueryCommandTree; | |
if (queryCommand != null) | |
{ | |
interceptionContext.Result = HandleQueryCommand(queryCommand); | |
} | |
} | |
} | |
private static DbCommandTree HandleQueryCommand(DbQueryCommandTree queryCommand) | |
{ | |
var newQuery = queryCommand.Query.Accept(new QueryVisitor()); | |
return new DbQueryCommandTree( | |
queryCommand.MetadataWorkspace, | |
queryCommand.DataSpace, | |
newQuery); | |
} | |
private class QueryVisitor : DefaultExpressionVisitor | |
{ | |
public override DbExpression Visit(DbJoinExpression expression) | |
{ | |
var visitor = new TemporalTableVisitor(); | |
visitor.ProcessExpression(expression.JoinCondition); | |
if (visitor.IsTemporalExpression) | |
{ | |
return DbExpressionBuilder.InnerJoin(expression.Left, expression.Right, visitor.Expression); | |
} | |
return base.Visit(expression); | |
} | |
} | |
private class TemporalTableVisitor | |
{ | |
private List<DbExpression> _expressions = new List<DbExpression>(); | |
public DbExpression Expression | |
{ | |
get | |
{ | |
if (_expressions.Count == 0) | |
{ | |
return null; | |
} | |
if (_expressions.Count == 1) | |
{ | |
return _expressions[0]; | |
} | |
return DbExpressionBuilder.And(_expressions[0], _expressions[1]); | |
} | |
} | |
private bool _isTemporalExpression = false; | |
public bool IsTemporalExpression | |
{ | |
get { return _isTemporalExpression && Expression != null; } | |
} | |
public void ProcessExpression(DbExpression expression) | |
{ | |
if (expression.ExpressionKind == DbExpressionKind.And) | |
{ | |
var endExpression = expression as DbAndExpression; | |
ProcessExpression(endExpression.Left); | |
ProcessExpression(endExpression.Right); | |
} | |
if (expression.ExpressionKind == DbExpressionKind.Equals) | |
{ | |
bool temporalComparision = false; | |
var equalExpression = expression as DbComparisonExpression; | |
var left = equalExpression.Left as DbPropertyExpression; | |
if (left != null) | |
{ | |
temporalComparision = _namesToIgnore.Any(n => left.Property.Name.Contains(n)); | |
} | |
var right = equalExpression.Right as DbPropertyExpression; | |
if (right != null) | |
{ | |
temporalComparision = temporalComparision | _namesToIgnore.Any(n => right.Property.Name.Contains(n)); | |
} | |
if (temporalComparision == false) | |
{ | |
_expressions.Add(expression); | |
} | |
else | |
{ | |
_isTemporalExpression = true; | |
} | |
} | |
} | |
} | |
} |
Jest to ten sam interceptor co wcześniej. Listing pokazuje tylko ten kod, który jest istotny w tym konkretnym problemie.
Podobnie jak wcześniej sprawdzamy typ modelu oraz typ komendy. Tym razem obsługujemy typ komendy Query (zapytanie SELECT). Robimy to w trochę inny sposób: korzystamy z ExpressionVisitor, który pozwala przejść po drzewie zapytania, przeanalizować je i ostatecznie zmodyfikować.
ExpressionVisitor umożliwia nam nadpisanie wielu metod, które są wywoływane dla innego typu Expression. W przykładzie nadpisujemy metodę Visit dla DbJoinExpression, ponieważ go chcemy zmienić. DbJoinExpression ma właściwość JoinCondition, która zawiera nasz warunek do zmiany.
Tutaj chciałbym podkreślić bardzo wyraźnie jedną rzecz. Zaproponowana implementacja zmiany warunku jest specyficzna dla tego przypadku i prawdopodobnie nie zadziała z każdym innym przypadkiem, który może się u Ciebie pojawić. Nie chciałem zbytnio komplikować kodu, dlatego musisz sam dopasować go do swoich potrzeb.
Modyfikacja warunku Joina
Na potrzeby analizy oraz zmiany warunku Joina przygotowałem dedykowaną klasę. Logikę wykorzystam później podczas zmiany generowania komendy Delete, więc od razy wydzieliłem ją do klasy.
Warunek Joina jest zbudowany z trzech typów wyrażeń. Pierwszym jest And, który łączy inne wyrażenia. Kolejnym jest wyrażenie Comparison, które porównuje wyrażenia Property.
W przykładzie z trzech (czy później w Delete czterech) wyrażeń połączonych And chcemy wybrać i zwrócić tylko te, który nie są powiązane z tymi dodatkowymi kolumnami. Dlatego przechodzimy rekurencyjnie po drzewie. W przypadku wyrażenia And analizujemy dalej lewą oraz prawą część wyrażenia. W przypadku wyrażenia Comparison sprawdzamy, czy lewe oraz prawe wyrażenia są typu Property i jakie są ich nazwy.
Klasa TemporalTableVisitor zawiera dwie właściwości. Expression zwróci wyrażenie, które nie jest powiązane z kolumnami ValidFrom oraz ValidTo (łączę znalezione warunki z listy _expressions). Natomiast IsTemporalExpression zwróci true, jeśli w warunku było wyrażenie z dodatkowymi kolumnami.
Właściwości te służą w QueryVisitor do zdecydowania, czy zmieniamy JoinCondition oraz na jaką wartość.
Po wykonaniu tego kodu zmienione zapytanie działa, nie powoduje błędów i wygląda tak:
SELECT CASE | |
WHEN ([UnionAll1].[C1] = 1) THEN '1X0X' | |
ELSE '1X1X' | |
END AS [C1], | |
[UnionAll1].[Id] AS [C2], | |
[UnionAll1].[ValidFrom] AS [C3], | |
[UnionAll1].[ValidTo] AS [C4], | |
[UnionAll1].[Name] AS [C5], | |
[UnionAll1].[CategoryId] AS [C6], | |
[UnionAll1].[Description] AS [C7], | |
[UnionAll1].[IsActive] AS [C8] | |
FROM [dbo].[OrderBaseProducts] AS [Extent1] | |
INNER JOIN (SELECT [Extent2].[Id] AS [Id], | |
[Extent2].[ValidFrom] AS [ValidFrom], | |
[Extent2].[ValidTo] AS [ValidTo], | |
[Extent2].[Name] AS [Name], | |
[Extent2].[CategoryId] AS [CategoryId], | |
[Extent2].[Description] AS [Description], | |
[Extent2].[IsActive] AS [IsActive], | |
cast(0 as bit) AS [C1] | |
FROM [dbo].[ProductsHistory] AS [Extent2] | |
UNION ALL | |
SELECT [Extent3].[Id] AS [Id], | |
[Extent3].[ValidFrom] AS [ValidFrom], | |
[Extent3].[ValidTo] AS [ValidTo], | |
[Extent3].[Name] AS [Name], | |
[Extent3].[CategoryId] AS [CategoryId], | |
[Extent3].[Description] AS [Description], | |
[Extent3].[IsActive] AS [IsActive], | |
cast(1 as bit) AS [C1] | |
FROM [dbo].[Products] AS [Extent3]) AS [UnionAll1] | |
ON [Extent1].[BaseProduct_Id] = [UnionAll1].[Id] | |
WHERE [Extent1].[Order_Id] = 5 /* @EntityKeyValue1 - [Order_Id] */ |
Usuwanie danych
Ostatnim problemem w przykładzie jest usuwanie danych z tabeli łączącej. Zapytanie, które generuje Entity Framework, wygląda tak:
DELETE [dbo].[OrderBaseProducts] | |
WHERE (((([Order_Id] = 5 /* @0 - [Order_Id] */) | |
AND ([BaseProduct_Id] = 5 /* @1 - [BaseProduct_Id] */)) | |
AND ([BaseProduct_ValidFrom] = '2019-03-11T04:59:03' /* @2 - [BaseProduct_ValidFrom] */)) | |
AND ([BaseProduct_ValidTo] = '2019-03-11T04:59:04' /* @3 - [BaseProduct_ValidTo] */)) |
I tutaj mamy bardzo podobny problem jak z Join, który rozwiążemy w ten sam sposób. Kod interceptora wygląda tak (pokazuję tylko nowy kod):
internal class TemporalTableCommandTreeInterceptor : IDbCommandTreeInterceptor | |
{ | |
private static readonly List<string> _namesToIgnore = new List<string> { "ValidFrom", "ValidTo" }; | |
public void TreeCreated(DbCommandTreeInterceptionContext interceptionContext) | |
{ | |
if (interceptionContext.OriginalResult.DataSpace == DataSpace.SSpace) | |
{ | |
var deleteCommand = interceptionContext.Result as DbDeleteCommandTree; | |
if (deleteCommand != null) | |
{ | |
interceptionContext.Result = HandleDeleteCommand(deleteCommand); | |
} | |
} | |
} | |
private static DbCommandTree HandleDeleteCommand(DbDeleteCommandTree deleteCommand) | |
{ | |
var predicate = deleteCommand.Predicate.Accept(new DeleteVisitor()); | |
return new DbDeleteCommandTree(deleteCommand.MetadataWorkspace, | |
deleteCommand.DataSpace, | |
deleteCommand.Target, | |
predicate); | |
} | |
private class DeleteVisitor : DefaultExpressionVisitor | |
{ | |
public override DbExpression Visit(DbAndExpression expression) | |
{ | |
var visitor = new TemporalTableVisitor(); | |
visitor.ProcessExpression(expression); | |
if (visitor.IsTemporalExpression) | |
{ | |
return visitor.Expression; | |
} | |
return base.Visit(expression); | |
} | |
} | |
} |
Rozwiązanie problemu jest bardzo podobne. Za pomocą klasy DeleteVisitor przechodzimy po warunku Delete i z niego usuwamy wyrażenia dla kolumn ValidFrom oraz ValidTo. W efekcie otrzymujemy zapytanie:
DELETE [dbo].[OrderBaseProducts] | |
WHERE (((([Order_Id] = 5 /* @0 - [Order_Id] */) | |
AND ([BaseProduct_Id] = 5 /* @1 - [BaseProduct_Id] */)) |
Przykład
Na githubie (https://github.com/danielplawgo/SqlHistory) znajduje się przykład do tego wpisu. Jest to rozbudowanie przykładu z wpisu o użyciu Temporal Table w Entity Framework. Po pobraniu przykładu należy w app.config ustawić connection string do testowej bazy.
Podsumowanie
Czasami wygenerowany przez Entity Framework SQL nie jest tym, czego potrzebujemy. Szczególnie gdy próbujemy zrobić coś, czego Entity Framework nie wspiera. W takiej sytuacji z pomocą przychodzą interceptory, które umożliwiają nam zmianę wygenerowanego SQL.
Myślę, że warto wiedzieć, jak wykorzystać interceptory. Mam nadzieję, że nie będziesz musiał zbyt często z nich korzystać. 🙂
1 thought on “Interceptory w Entity Framework”