Home .NET Les tables temporelles – Partie 3 : Utilisation avec Entity Framework Core 6

Les tables temporelles – Partie 3 : Utilisation avec Entity Framework Core 6

  Gilles, Architecte technique .NET 30 min 21 février 2022

Nous avons vu dans la première partie de l’article ce que sont les tables temporelles, quels sont leurs intérêts et comment les manipuler via des commandes SQL, puis dans une seconde partie comment les utiliser avec EF Core grâce à l’extension NuGet EfCoreTemporalTable. Dans cette troisième partie nous allons voir comment les utiliser nativement via une nouveauté dans l’ORM Entity Framework Core (EF Core) 6+.

Quoi de neuf sur la temporalité dans EF Core 6 ?

Suite au manque de support des tables temporelles de l’ORM et face à la forte demande de la communauté (GitHub #4693), Entity Framework les supporte enfin depuis la version disponible en date de Novembre 2021 :

  • Création de tables temporelles à l’aide de migrations
  • Transformation de tables existantes en tables temporelles, à nouveau à l’aide de migrations
  • Interrogation des données historique
  • Restauration des données à partir d’un point antérieur dans le passé

Toutes ces opérations étaient auparavant plus ou moins réalisables via des extensions NuGet, dorénavant elles font enfin directement parties d’EF Core !

Nous allons donc dans un premier temps créer une logique similaire au précédent article (utilisant des extensions NuGet) mais avec cette fois EF Core 6, en utilisant par ailleurs les migrations.

Création du projet

Création d’une application Console de démo avec .NET 6.0

dotnet new console -f net6.0

Installation des NuGet EF SQL Server :

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 6.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 6.0

Création du modèle – Une entreprise possèdes des employés :

public partial class Employe
{
    public int Id { get; set; }
    public string Prenom { get; set; }
    public string Nom { get; set; }
    public int EntrepriseId { get; set; }

    public virtual Entreprise Entreprise { get; set; }
}
public partial class Entreprise
{
    public Entreprise()
    {
        Employe = new HashSet<Employe>();
    }

    public int Id { get; set; }
    public string Nom { get; set; }
    public string Adresse { get; set; }

    public virtual ICollection<Employe> Employe { get; set; }
}

Création du contexte :

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        //Définitions sans temporalité
        modelBuilder.Entity<Employe>(entity =>
        {
            entity.Property(e => e.Nom)
                .IsRequired()
                .HasMaxLength(80);

            entity.Property(e => e.Prenom)
                .IsRequired()
                .HasMaxLength(80);

            entity.HasOne(d => d.Entreprise)
                .WithMany(p => p.Employe)
                .HasForeignKey(d => d.EntrepriseId)
                .OnDelete(DeleteBehavior.ClientSetNull)
                .HasConstraintName("FK_Employe_Entreprise");
        });

        modelBuilder.Entity<Entreprise>(entity =>
        {
            entity.Property(e => e.Adresse).HasMaxLength(80);
            entity.Property(e => e.Nom).HasMaxLength(80);

        });

        //Ajout temporalités
        modelBuilder
            .Entity<Employe>()
            .ToTable("Employe", b => b.IsTemporal());

        modelBuilder.Entity<Entreprise>()
            .ToTable(
                "Entreprise",
                b => b.IsTemporal(
                    b =>
                    {
                        //Changement des noms par défaut
                        b.HasPeriodStart("ValideDu");
                        b.HasPeriodEnd("ValideAu");
                        b.UseHistoryTable("EntrepriseHistorique");
                    }));

        OnModelCreatingPartial(modelBuilder);
    }

Nous avons décomposé la création du modèle en 2 parties, d’abord la partie sans temporalité puis en toute fin l’ajout de temporalité (IsTemporal()) :

  • Employe : Utilisation des défauts
  • Entreprise : Utilisation d’un nom spécifique à la table historique (EntrepriseHistorique) ainsi que pour les colonnes temporelles (ValideDu et ValideAu).

Création d’une migration

Nous allons maintenant vérifier si les migrations EF Core 6 supportent en effet la temporalité.

Initialisation d’une migration EF (doc officielle migrations) :

dotnet ef migrations add Initial

Cette commande va vous générer 2 fichiers dans le répertoire « Migrations » :

  • 20211211104839_Initial.cs
  • DemoTemporelleContextModelSnapshot.cs

Vérifions ce que EF nous génère dans « 20211211104839_Initial.cs » :

        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Entreprise",
                columns: table => new
                {
                    Id = table.Column<int>(type: "int", nullable: false)
                        .Annotation("SqlServer:Identity", "1, 1"),
                    Nom = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
                    Adresse = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
                    ValideAu = table.Column<DateTime>(type: "datetime2", nullable: false)
                        .Annotation("SqlServer:IsTemporal", true)
                        .Annotation("SqlServer:TemporalPeriodEndColumnName", "ValideAu")
                        .Annotation("SqlServer:TemporalPeriodStartColumnName", "ValideDu"),
                    ValideDu = table.Column<DateTime>(type: "datetime2", nullable: false)
                        .Annotation("SqlServer:IsTemporal", true)
                        .Annotation("SqlServer:TemporalPeriodEndColumnName", "ValideAu")
                        .Annotation("SqlServer:TemporalPeriodStartColumnName", "ValideDu")
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Entreprise", x => x.Id);
                })
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EntrepriseHistorique")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null)
                .Annotation("SqlServer:TemporalPeriodEndColumnName", "ValideAu")
                .Annotation("SqlServer:TemporalPeriodStartColumnName", "ValideDu");

            migrationBuilder.CreateTable(
                name: "Employe",
                columns: table => new
                {
                    Id = table.Column<int>(type: "int", nullable: false)
                        .Annotation("SqlServer:Identity", "1, 1"),
                    Prenom = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
                    Nom = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
                    EntrepriseId = table.Column<int>(type: "int", 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_Employe", x => x.Id);
                    table.ForeignKey(
                        name: "FK_Employe_Entreprise",
                        column: x => x.EntrepriseId,
                        principalTable: "Entreprise",
                        principalColumn: "Id");
                })
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null)
                .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
                .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

            migrationBuilder.CreateIndex(
                name: "IX_Employe_EntrepriseId",
                table: "Employe",
                column: "EntrepriseId");
        }

Nous pouvons remarquer :

  • La temporalité est bien prise en compte
  • Pour Employe nous n’avions pas spécifié de nom de colonne temporelle, elle seront par défaut nommées « PeriodStart » et « PeriodeEnd »
  • Pour Entreprise en revanche nous avions spécifié des noms temporels personnalisés et ils semblent effectivement pris en compte, idem pour le nom de table historique

Générons la base de données dans SQL Server :

dotnet ef database update

Nous retrouvons bien la génération de notre schéma avec la temporalité :

Ajout d’un jeu de données

Nous allons ajouter 2 entreprises et 3 employés ainsi :


var db = new DemoTemporelleContext();
//Re-création DB
//db.Database.EnsureDeleted();
//db.Database.EnsureCreated();

//Ajout d'un jeu de données :
Console.WriteLine("Ajout d'un jeu de données...");
//- Entreprises
var entreprise1 = new Entreprise { Nom = "Webnet", Adresse = "1, avenue de la Cristallerie 92310 SEVRES" };
db.Entreprise.Add(entreprise1);
var entreprise2 = new Entreprise { Nom = "Microsoft", Adresse = "37 Quai du Président Roosevelt 92130 Issy-les-Moulineaux" };
db.Entreprise.Add(entreprise2);
//- Employés
var employe1 = new Employe { Prenom = "Gilles", Nom = "Lautrou", Entreprise = entreprise1 };
db.Employe.Add(employe1);
var employe2 = new Employe { Prenom = "Patricia", Nom = "Martin", Entreprise = entreprise2 };
db.Employe.Add(employe2);
var employe3 = new Employe { Prenom = "Jacques", Nom = "Dupont", Entreprise = entreprise2 };
db.Employe.Add(employe3);
//- Persist
db.SaveChanges();

Puis modifier ces données :

var dateAjout = DateTime.Now;
Thread.Sleep(5_000); //Attente 5 secondes pour mieux différencier les dates

//Modification du jeu de données :
Console.WriteLine("Modification du jeu de données...");
//- "Patricia Martin" change de nom en "Martin Durant"
employe2.Nom = "Martin Durant";
//- "Jacques Dupont" rejoint "Webnet"
employe3.EntrepriseId = 1;
//- Renommer "Microsoft" en "Microsoft France"
entreprise2.Nom = "Microsoft France";
//- Persist
db.SaveChanges();

A noter que nous avons effectué une pause de 5 secondes entre ajout/modification afin de mieux distinguer les périodes temporelles.

Vérifions ce que contient la base de données :

Nous retrouvons bien nos 3 modifications historisées, effectuons maintenant ce travail avec EF Core 6.

Requêtage sans temporalité

Affichage des noms employé/entreprise actuels :

Console.WriteLine("Requêtage sans temporalité...");
db.Employe
    .Select(i => $"\t- {i.Prenom} {i.Nom} - {i.Entreprise.Nom}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);

Résultat :

Nous retrouvons donc les dernières données présentes en base. Vérifions maintenant les anciennes données.

Requêtage avec temporalité

Données initialement ajoutées

Retrouvons l’ensemble des données initialement ajoutées :

db.Employe
    .TemporalAsOf(dateAjout.ToUniversalTime()) //Temporal=UTC, donc ne pas oublier ToUniversalTime()
    .Select(i => $"\t\t- {i.Prenom} {i.Nom} - {i.Entreprise.Nom}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);

Attention à bien utiliser des dates UTC lors du requêtage temporel, aussi bien côté Entity Framework que SQL. Pour convertir une date C# en UTC il suffit d’utiliser .ToUniversalTime().

Résultats :

Nous retrouvons bien les données que nous avions initialement insérées.

Les tables jointes bénéficient automatiquement du filtre de temporalité, le comportement étant identique avec l’utilisation d’Include().

Ensemble des données

db.Employe
    .TemporalAll()
    .OrderBy(i => EF.Property<DateTime>(i, "PeriodStart"))
    .Select(i => $"\t\t- {i.Prenom} {i.Nom} - début = {EF.Property<DateTime>(i, "PeriodStart")}, fin = {EF.Property<DateTime>(i, "PeriodEnd")}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);

Résultats :

Nous retrouvons cette fois cinq lignes. Pour rappel nous avions effectué les modifications suivantes :

  • « Patricia Martin » change de nom en « Martin Durant »
  • « Jacques Dupont » rejoint « Webnet »
  • Renommer « Microsoft » en « Microsoft France »

Sur nos trois modifications, deux concernaient l’employé (changement nom + changement entreprise associée) et une concernait l’Entreprise (changement nom). Puisque la seule table de notre requête était Employe nous affichons toutes les modifications ayant eu lieu seulement sur cette table, soit trois insertions et deux modifications, soit cinq lignes.

Il est possible de récupérer les valeurs des colonnes temporelles via EF.Property<DateTime>(i, « PeriodStart »), nous en reparlerons en fin d’article.

Gestion des migrations de modification de colonnes de tables temporelles

Nous allons voir si les migrations fonctionnent avec l’ajout puis la suppression d’une colonne Email :

//public partial class Employe
//{
//    public string Email { get; set; }
//}

Migration d’ajout de colonne SQL :

dotnet ef migrations add "Ajout Employe.Email"

Code généré par EF Core :

    public partial class AjoutEmployeEmail : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.AddColumn<string>(
                name: "Email",
                table: "Employe",
                type: "nvarchar(max)",
                nullable: false,
                defaultValue: "");
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "Email",
                table: "Employe")
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null);
        }
    }

Exécution de la migration :

dotnet ef database update

La migration fonctionne correctement et la table « EmployeHistory » contient également notre nouvelle colonne, supprimons-là du modèle puis :

dotnet ef migrations add "Suppression Employe.Email"
    public partial class SuppressionEmployeEmail : Migration
    {
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "Email",
                table: "Employe")
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null);
        }

        protected override void Down(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.AddColumn<string>(
                name: "Email",
                table: "Employe",
                type: "nvarchar(max)",
                nullable: false,
                defaultValue: "");
        }
    }
dotnet ef database update

La colonne a correctement été supprimée des tables « Employe » et « EmployeHistory ».

Attention : Commenter les lignes « EnsureDeleted() » et « EnsureCreated() » lors du test des migrations, le cas échéant la table d’historique des migrations « [__EFMigrationsHistory] » sera supprimée et EF tentera en vain de réexécuter toutes les migrations ou se baser sur le modèle (qui n’est pas forcément à jour).

 

Gestion des migrations d’ajout/suppression temporalité

Pour retirer la temporalité d’une table il suffit de supprimer/commenter son flag associé :

.ToTable("Employe"/*, b => b.IsTemporal()*/);

Migration du retrait de temporalité :

dotnet ef migrations add "Suppression temporalité Employe"

Code généré par EF Core :

        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.DropColumn(
                name: "PeriodEnd",
                table: "Employe")
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null)
                .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
                .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

            migrationBuilder.DropColumn(
                name: "PeriodStart",
                table: "Employe")
                .Annotation("SqlServer:IsTemporal", true)
                .Annotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .Annotation("SqlServer:TemporalHistoryTableSchema", null)
                .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
                .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

            migrationBuilder.AlterTable(
                name: "Employe")
                .OldAnnotation("SqlServer:IsTemporal", true)
                .OldAnnotation("SqlServer:TemporalHistoryTableName", "EmployeHistory")
                .OldAnnotation("SqlServer:TemporalHistoryTableSchema", null)
                .OldAnnotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
                .OldAnnotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
        }

Exécution de la migration :

dotnet ef database update

Les modifications sont correctement appliquées sur le schéma SQL.

Important : Comme nous l’avons constaté le retrait de temporalité supprime les 2 colonnes ainsi que l’historique, attention à la perte des données associées (par exemple les 2 colonnes système pourraient être déjà utilisées comme valeur de date création et modification ailleurs dans le code.

Récupération des dates de temporalité

Entity Framework gère les colonnes temporelles en tant que shadow property. Cela signifie qu’il est possible de les utiliser mais seulement de façon explicite via EF.Property<DateTime>(i, « MonNomColonne »).

Exemple de récupération des valeurs :

db.Employe
    .TemporalAll()
    .OrderBy(i => EF.Property<DateTime>(i, "PeriodStart"))
    .Select(i => $"\t\t- {i.Prenom} {i.Nom} - début = {EF.Property<DateTime>(i, "PeriodStart")}, fin = {EF.Property<DateTime>(i, "PeriodEnd")}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);

SQL généré :

SELECT [e].[Prenom], [e].[Nom], [e].[PeriodStart], [e].[PeriodEnd]
FROM [Employe] FOR SYSTEM_TIME ALL AS [e]
ORDER BY [e].[PeriodStart]

Ces colonnes sont de type système et gérés pas la base de données, d’où la raison qu’EF les considère comme shadow puisque non-modifiables. Le principe est le même lorsqu’on exécute la requête « SELECT * FROM [Employe] », pour récupérer les colonnes temporelles il faut l’écrire ainsi « SELECT *, PeriodStart, PeriodEnd FROM [Employe] ».

Récupération des anciennes données

Restauration données encore existantes

Pour revenir à une ancienne version des données, il suffit de récupérer la version souhaitée via TemporalAll() puis de la définir en tant que EntityState.Modified afin qu’EF puisse générer la requête UPDATE associée :

//- Restaurer ancienne version des données : "Microsoft France" en "Microsoft"
Console.WriteLine("\t> Ancienne version existante :");
db = new DemoTemporelleContext();
var entrepriseModifiee = db.Entreprise
    .TemporalAll()
    .Where(i => i.Id == entreprise2.Id)
    .OrderBy(i => EF.Property<DateTime>(i, "ValideDu"))
    .First();
db.Entry(entrepriseModifiee).State = EntityState.Modified;
db.SaveChanges();
db.Entreprise
    .Select(i => $"\t\t- {i.Nom}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);

Résultat :

Restauration données déjà supprimées

Le principe de fonctionnement est identique à des données existantes, il suffit de remplacer EntityState.Modified par EntityState.Added (ou de l’ajouter au DbContext comme ici) :

//- Restaurer donnée supprimée : Jacques Dupont
db = new DemoTemporelleContext();
Console.WriteLine("\t> Donnée supprimée :");
var idEmployeSupprime = employe3.Id;
db.Employe.Remove(employe3);
db.SaveChanges();
db.Employe
    .Select(i => $"\t\t- Avant : {i.Prenom} {i.Nom}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)
);
var employeSupprimee = db.Employe
    .TemporalAll()
    .Where(i => i.Id == idEmployeSupprime)
    .OrderBy(i => EF.Property<DateTime>(i, "PeriodStart"))
    .Last();
employeSupprimee.Id = 0; //Auto-increment, nécessaire de réinitialiser pour éviter exception
db.Employe.Add(employeSupprimee);
db.SaveChanges();
db.Employe
    .Select(i => $"\t\t- Après : {i.Prenom} {i.Nom}")
    .ToList()
    .ForEach(i => Console.WriteLine(i)

Résultat :

Il a fallu ici réinitialiser l’Id puisqu’il est auto-incrémenté et ne permet donc pas de définir une valeur spécifique.

Conclusion

A travers cet article nous avons pu constater le très grand travail réalisé sur Entity Framework 6 afin de le rendre parfaitement compatible avec les tables temporelles, notamment :

  • Création de tables temporelles
  • Migrations temporelles
  • Requêtage historique
  • Restauration données

Il n’y a dorénavant plus besoin de de multiplier les extensions, la temporalité étant maintenant parfaitement prise en charge par Entity Framework.

Vous pouvez retrouver l’ensemble du code source de cet article sur GitHub.

Liens utiles :

Lire les articles similaires

Laisser un commentaire

Social Share Buttons and Icons powered by Ultimatelysocial