EF Core 6 Temporal Tables

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.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

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; }
}
view raw Product.cs hosted with ❤ by GitHub

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());
}
}
view raw DataContext.cs hosted with ❤ by GitHub

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");
}
}
view raw AddProducts.cs hosted with ❤ by GitHub

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:

Baza danych z Temporal Table

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();
}
view raw Program.cs hosted with ❤ by GitHub

Po wykonaniu powyższego kodu w nowo utworzonej bazie danych, tabela Products będzie pusta:

Pusta tabela

Natomiast tabela ProductsHistory będzie zawierać dwa wpisy:

Historia rekordu utworzona prez Tempral Table

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:

Wynik działania aplikacji z Temporal Table

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.

Szkolenie Mikroserwisy w .NET 5

Szkolenie Mikroserwisy w .NET 5

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o tworzeniu mikroserwisów w .NET.

1 thought on “EF Core 6 Temporal Tables

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.