Wprowadzenie
Kilka miesięcy temu miała swoją premierę kolejna wersja .NET, a wraz z nią również kolejna wersja Entity Framework Core. W tym wpisie chciałbym się skupić na jednej z nowości, która została dodana do Entity Framework Core, a jest nią obsługa mechanizmu Temporal Tables z SQL Server. O mechanizmie tym pisałem już kiedyś w kontekście zwykłego Entity Framework, który nie miał dla niego wsparcia. Od wersji 6 Entity Framework Core mamy oficjalne wsparcie dla Temporal Tables i chciałbym to dzisiaj Ci pokazać.
Temporal Tables
O samym Temporal Tables możesz przeczytać w dedykowanym wpisie, który znajduje się na moim blogu już od jakiegoś czasu. W skrócie jest to wbudowany w SQL Server mechanizm do przechowywania danych historycznych. Sam proces tworzenia historycznych wpisów jest robiony automatycznie, a my musimy tylko uruchomić go dla interesujących nas tabel. Do dedykowanej tabeli będą wpadały dane historyczne wraz z informacją od kiedy do kiedy one obowiązywały.
Po więcej szczegółów odsyłam do dedykowanego wpisu o Temporal Tables.
Temporal Tables i EF Core
Od wersji 6 Entity Framework Core możemy już używać Temporal Tables bezpośrednio z poziomu EF Core. Na potrzeby przykładu użyję prostej klasy Product, która wygląda tak:
public class Product | |
{ | |
public Guid Id { get; set; } | |
public string Name { get; set; } | |
public string Description { get; set; } | |
} |
Nie ma w niej nic szczególnego. Zwróć uwagę, że w tym momencie nie dodałem do klasy Product dwóch właściwości/kolumn określających zakres dat obowiązywania danego rekordu. Nie musimy dodawać ich do samego modelu, ale Entity Framework Core będzie je wykorzystywał pod spodem. Zobaczysz to za moment w wygenerowanej migracji.
Do włączenie mechanizmu Temporal Tables dla klasy Product wykorzystam Fluent API i metodę OnModelCreating z klasy DbContext. W realnej aplikacji raczej wykorzystałbym klasy implementujące interfejs IEntityTypeConfiguration, ale by uprościć przykład, nie będę tego teraz robił.
Klasa DataContext z konfiguracją wygląda tak:
public class DataContext : DbContext | |
{ | |
public DbSet<Product> Products { get; set; } | |
protected override void OnModelCreating(ModelBuilder modelBuilder) | |
{ | |
base.OnModelCreating(modelBuilder); | |
modelBuilder.Entity<Product>() | |
.ToTable("Products", b => b.IsTemporal()); | |
} | |
} |
W linijce 9 zaczyna się konfiguracja tabeli dla klasy Product. W metodzie ToTable poza nazwą tabeli określamy również, że tabela ta będzie miała uruchomiony mechanizm Temporal Table. Do metody IsTemporal możemy przekazać kolejnego buildera, w którym konfigurujemy nazwę tabeli historycznej, czy nazwy kolumn z datami. W przykładzie nie będę tego ustawiał i zostawię domyślne wartości.
Dla powyższej konfiguracji wygenerowana została taka migracja:
public partial class AddProducts : Migration | |
{ | |
protected override void Up(MigrationBuilder migrationBuilder) | |
{ | |
migrationBuilder.CreateTable( | |
name: "Products", | |
columns: table => new | |
{ | |
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false), | |
Name = table.Column<string>(type: "nvarchar(max)", nullable: false), | |
Description = table.Column<string>(type: "nvarchar(max)", nullable: false), | |
PeriodEnd = table.Column<DateTime>(type: "datetime2", nullable: false) | |
.Annotation("SqlServer:IsTemporal", true) | |
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") | |
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"), | |
PeriodStart = table.Column<DateTime>(type: "datetime2", nullable: false) | |
.Annotation("SqlServer:IsTemporal", true) | |
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") | |
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart") | |
}, | |
constraints: table => | |
{ | |
table.PrimaryKey("PK_Products", x => x.Id); | |
}) | |
.Annotation("SqlServer:IsTemporal", true) | |
.Annotation("SqlServer:TemporalHistoryTableName", "ProductsHistory") | |
.Annotation("SqlServer:TemporalHistoryTableSchema", null) | |
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") | |
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); | |
} | |
protected override void Down(MigrationBuilder migrationBuilder) | |
{ | |
migrationBuilder.DropTable( | |
name: "Products") | |
.Annotation("SqlServer:IsTemporal", true) | |
.Annotation("SqlServer:TemporalHistoryTableName", "ProductsHistory") | |
.Annotation("SqlServer:TemporalHistoryTableSchema", null) | |
.Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd") | |
.Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart"); | |
} | |
} |
Z niej możemy wyczytać, że tabela z danymi historycznymi będzie miała nazwę ProductsHistory. Natomiast kolumny odpowiednio PeriodStart oraz PeriodEnd.
W samym SQL Server baza danych po wykonaniu migracji wygląda tak:
Użycie Temporal Tables w kodzie
Zaczniemy od przygotowania historii dla testowego produktu. W tym celu przygotowałem trzy proste metody. Pierwsza doda produkt, druga go zmieni, a trzecia usunie:
var id = await CreateProduct(); | |
await UpdateProduct(id); | |
await RemoveProduct(id); | |
static async Task<Guid> CreateProduct() | |
{ | |
await using DataContext db = new DataContext(); | |
var product = new Product() | |
{ | |
Id = Guid.NewGuid(), | |
Name = "product name", | |
Description = "product description" | |
}; | |
await db.Products.AddAsync(product); | |
await db.SaveChangesAsync(); | |
return product.Id; | |
} | |
static async Task UpdateProduct(Guid id) | |
{ | |
await using DataContext db = new DataContext(); | |
var product = await db.Products.FirstAsync(p => p.Id == id); | |
product.Name = "new product name"; | |
await db.SaveChangesAsync(); | |
} | |
static async Task RemoveProduct(Guid id) | |
{ | |
await using DataContext db = new DataContext(); | |
var product = await db.Products.FirstAsync(p => p.Id == id); | |
db.Products.Remove(product); | |
await db.SaveChangesAsync(); | |
} |
Po wykonaniu powyższego kodu w nowo utworzonej bazie danych, tabela Products będzie pusta:
Natomiast tabela ProductsHistory będzie zawierać dwa wpisy:
Historię możemy również wyświetlić w samej aplikacji. Użyjemy do tego metody TemporalAll w zapytaniu Linq:
static async Task DisplayHistoryWithDates(Guid id) | |
{ | |
await using DataContext db = new DataContext(); | |
var historyItems = await db.Products | |
.TemporalAll() | |
.Where(p => p.Id == id) | |
.Select(p => new | |
{ | |
p.Name, | |
p.Description, | |
PeriodStart = EF.Property<DateTime>(p, "PeriodStart"), | |
PeriodEnd = EF.Property<DateTime>(p, "PeriodEnd") | |
}) | |
.ToListAsync(); | |
foreach (var item in historyItems) | |
{ | |
Console.WriteLine($"{item.Name}: {item.Description}, Start: {item.PeriodStart}, End: {item.PeriodEnd}"); | |
} | |
} |
Zapytanie zaczynamy budować od wywołania metody TemporalAll (linijka 6), natomiast reszta zapytania jest standardowa. Co ciekawe, możemy również pobrać z bazy daty obowiązywania danego rekordu z historii, nawet jeśli nie mamy ich w samym modelu. W tym celu możemy użyć metody EF.Property, w której przekazujemy nazwę kolumny. Użycie tego widać powyżej w linijce 12 i 13.
Powyższa metoda wykonana na bazie następującego zapytania:
exec sp_executesql N'SELECT [p].[Name], [p].[Description], [p].[PeriodStart], [p].[PeriodEnd] | |
FROM [Products] FOR SYSTEM_TIME ALL AS [p] | |
WHERE [p].[Id] = @__id_0',N'@__id_0 uniqueidentifier',@__id_0='DD2E351A-25B6-4638-8FD0-EE40F82C57E3' |
Natomiast na konsoli wyświetli się coś takiego:

Do dyspozycji mamy kilka innych metod:
- TemporalAsOf – zwraca wersję rekordu z określonego punktu w czasie,
- TemporalAll – zwraca całą historię rekordu – przykład powyżej,
- TemporalFromTo – zwraca wiersze, które były aktywne w przekazanym okresie czasu (PeriodEnd jest mniejsze niż To, PeriodStart może być mniejsze niż From),
- TemporalBetween – działa podobnie jak TemporalFromTo, tylko z tą różnicą, że dodatkowo zostaną zwrócone rekordy, których PeriodStart jest w zakresie, a PeriodEnd może być już poza górnym zakresem,
- TemporalContainedIn – zwraca rekordy, których PeriodStart oraz PeriodEnd są w przekazanym zakresie.
Przykład
Na githubie znajduje się przykład do tego wpisu – https://github.com/danielplawgo/EFCoreTemporalTablesTests. Wykorzystuje on bazę danych znajdującą się w docker. W pierwszej kolejności uruchamiamy bazę danych w docker (komenda „docker-compose up” w głównym folderze w repozytorium), a później możemy już uruchamiać właściwą aplikację.
Podsumowanie
Temporal Tables jest przydatnym mechanizmem w sytuacji, gdy potrzebujemy przechowywać historię rekordów. Dzięki temu, że jest wbudowany w SQL Server, możemy go bardzo szybko uruchomić. Sam nie raz go szybko konfigurowałem dla określonych tabel w bazie danych, aby zbierać dane. A samą funkcjonalność wyświetlania historii dopiero z czasem budowałem w aplikacji.
Teraz, gdy Entity Framework Core ma wsparcie dla Temporal Tables, wykorzystywanie tego mechanizmu będzie dużo łatwiejsze niż miało to miejsce wcześniej.
1 thought on “EF Core 6 Temporal Tables”