EF Core Database First - Personnaliser le code généré (3/4)

Brouillon

À venir

0 commentaire

EF Core Database - Art3

Dans l'article précédent, nous avons vu comment générer un DbContext et des entités à partir d'une base de données existante.

Lorsque l'on utilise l'approche Database First avec EF Core, il peut arriver que l'on souhaite personnaliser le code généré. Dans cet article, nous allons voir comment on peut arriver à le faire.

Avant de commencer, il est nécessaire de préciser qu'Entity Framework Core a été conçu pour une approche Code First. De ce fait, tout ce que nous allons voir dans cet article et les suivants, est susceptible d'être modifié voire supprimé. Cependant, il peut arriver que l'on ait besoin, pour une raison ou une autre (base de données d'une appli existante, ...), de faire du Database First avec EF Core.

Dans cette série d'articles EF Core Database First, nous allons aborder différentes thématiques autour du Database First avec Entity Framework Core, dont voici la liste :

  1. Visite guidée
  2. Les outils en ligne de commande
  3. Personnaliser le code généré

    ( cet article)

  4. Quelques astuces utiles

Avant de commencer

Avant de commencer, je vous invite à récupérer la solution servant de point de départ à notre article : https://github.com/adrenalinedj/efcoredbfirstexample. Il s'agit de la solution utilisée précédemment comportant quelques modifications que nous allons détailler. Le DbContext et les entités ont été générées au moyen de cette commande dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=EFCoreDBFirstMyStore;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -o DAL -c MyStoreContext.

Dans le cadre de cet article, nous allons voir comment personnaliser le code généré à la fois pour le DbContext et les entités. Pour cela, nous allons prendre 2 cas de figure :

  • Dans la méthode OnConfiguring, EF Core a généré du code contenant la connectionstring utilisée lors de la génération du DbContext. C'est quelque peu problématique de conserver la connectionstring tel quel dans notre code : nous allons donc faire en sorte que la méthode OnConfiguring soit vide. Nous verrons dans un autre article une autre manière pour éviter ce désagrément.
  • Admettons, que dans le cadre du développement d'une application, nous souhaitions avoir des informations "d'audit" sur chaque table : date de création et date de dernière modification. Nous verrons comment nous pouvons faire pour gérer cela en personnalisant le code de chaque entité et du DbContext.

ATTENTION, pour rappel, l'équipe de développement de EF Core signale que cette API n'est pas prévue pour être utiliser directement et qu'elle peut être modifiée ou supprimée dans des versions ultérieures :

This API supports the Entity Framework Core infrastructure and is not intended to be used directly from your code. This API may change or be removed in future releases.

Les design-time services

Les outils, que l'on a vu dans l'article précédent, sont exécutés design-time et peuvent nécessiter un accès à des services (au sens .Net Core) définis dans notre application. Pour cela, ils utilisent les design-time services : des classes permettant de définir les services qui seront disponibles au moment où les outils s'exécuteront. C'est, entre autres choses, pour cela que la commande scaffold ne peut s'exécuter que si le projet compile : il faut qu'elle puisse utiliser les design-time services, s'il y'en a.

Ces classes doivent implémenter l'interface IDesignTimeServices (namespace Microsoft.EntityFrameworkCore.Design) présente dans l'assembly de EF Core (Microsoft.EntityFrameworkCore.dll) définissant une seule méthode ConfigureDesignTimeServices prenant en paramètre un IServiceCollection.

Si vous avez l'habitude de travailler avec ASP.Net Core, vous savez à quoi sert IServiceCollection : à "configurer" l'injection de dépendances de vos "services".

Ici, c'est le même principe. Il faut définir quels vont être les types concrets qui seront utilisés pour la personnalisation du code. Etant donné qu'il s'agit de l'exécution d'une commande avec une durée de vie courte (le temps de la génération du code), les services seront déclarés en Singleton.

Voici la classe MyStoreDesignTimeService avec la déclaration des 2 classes que nous allons utiliser pour la personnalisation du code généré :

using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using Microsoft.Extensions.DependencyInjection;

namespace EFCoreDBFirst.MyStore
{
    public class MyStoreDesignTimeService : IDesignTimeServices
    {
        public void ConfigureDesignTimeServices(IServiceCollection serviceCollection)
        {
            serviceCollection.AddSingleton<ICSharpDbContextGenerator, MyStoreDbContextGenerator> ();
            serviceCollection.AddSingleton<ICSharpEntityTypeGenerator, MyStoreEntityTypeGenerator> ();
        }
    }
}

À noter : l'avantage d'utiliser une classe spécifique, pour définir les design-time services plutôt que d'ajouter cela à la classe Startup, est d'éviter que cela ne puisse rentrer en conflit ou perturber le bon fonctionnement de l'application.

Génération du DbContext

Pour rappel, à titre d'exemple, nous allons personnaliser la génération du DbContext pour que la méthode OnConfiguring soit vide et que nous n'ayons plus la connectionstring dedans. (Nous verrons dans un prochain article, comment faire pour ne pas avoir de connectionstring sans avoir à personnaliser la génération du DbContext).

Dans la section précédente, nous avons déclaré un design-time service au moyen de la classe MyStoreDbContextGenerator implémentant l'interface ICSharpDbContextGenerator.

Etant donné que l'on souhaite personnaliser le code du DbContext, notre classe MyStoreDbContextGenerator va hériter de CSharpDbContextGenerator, elle même implémentant l'interface ICSharpDbContextGenerator.

Pour cela, il faudra :

  • écrire un constructeur : la classe CSharpDbContextGenerator ne définissant pas de constructeur sans paramètre.
  • surcharger la méthode WriteCode qui est en charge de la génération du code du DbContext.

Voici le code de la MyStoreDbContextGenerator, que nous allons détailler ensuite :

using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Scaffolding;
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace EFCoreDBFirst.MyStore
{
    public class MyStoreDbContextGenerator : CSharpDbContextGenerator
    {
        public MyStoreDbContextGenerator(
#pragma warning disable CS0618 // Type or member is obsolete
            IEnumerable<IScaffoldingProviderCodeGenerator> legacyProviderCodeGenerators,
#pragma warning restore CS0618 // Type or member is obsolete
            IEnumerable<IProviderConfigurationCodeGenerator> providerCodeGenerators,
            IAnnotationCodeGenerator annotationCodeGenerator,
            ICSharpHelper cSharpHelper
        ) : base(legacyProviderCodeGenerators, providerCodeGenerators, annotationCodeGenerator, cSharpHelper)
        {
        }

        public override string WriteCode(IModel model, string @namespace, string contextName, string connectionString,
            bool useDataAnnotations, bool suppressConnectionStringWarning)
        {
            var code = base.WriteCode(model, @namespace, contextName, connectionString, useDataAnnotations, suppressConnectionStringWarning);

            var pattern = @"(protected override void OnConfiguring\(DbContextOptionsBuilder optionsBuilder\)\r\n\s*\{)((.|\n)*)(\r\n\s*\}(\r\n){2})";
            var regex = new Regex(pattern, RegexOptions.Compiled);
            return regex.Replace(code, "$1$4");
        }
    }
}

Le constructeur ne servant que de passe-plat, il n'y a pas grand chose à dire : il se contente de passer ses arguments au constructeur de sa classe de base CSharpDbContextGenerator.

Concernant la méthode WriteCode :

  • Elle renvoie le code généré sous la forme d'une chaine de caractère. Cela nous oblige à devoir faire du search and replace au moyen d'une expression régulière pour ensuite ne garder que les parties du code qui nous intéressent.
  • Elle prend en paramètres :
    • IModel model contient tout le modèle de données. C'est à partir de ce modèle que le code sera généré par la classe de base (base.WriteCode()).
    • string @namespace contient le namespace dans lequel va être généré le DbContext.
    • string contextName contient le nom du DbContext : soit le nom par défaut soit celui que l'on aura choisi en utilisant l'option c (ou --context).
    • bool useDataAnnotations permet de savoir si la commande scaffold a été appelée avec l'option -d | --data-annotations.
    • bool suppressConnectionStringWarning permet de savoir s'il faut générer le warning à propos de la connectionstring.

Dans notre cas d'exemple, nous commençons par récupérer le code généré par la classe de base (base.WriteCode()) pour ensuite appliquer une expression régulière nous permettant de savoir où commence et où termine la méthode OnConfiguring. Enfin, on assemble les 2 parties qui nous intéressent ($1 et $4) pour obtenir la méthode vide.

Génération des entités

Pour l'exemple, nous allons mettre en place des champs d'audit : date de création et date de modification.

Nous allons supposer, qu'il s'agit du développement d'une application utilisant une base de données existante pour laquelle nous ne pouvons modifier la structure. Les informations que nous avons sont celles-ci :

  • La date de création est un champ de type DateTime non nullable et sans valeur par défaut.
  • La date de modification est un champ de type DateTime non nullable et sans valeur par défaut.
  • Dans les 2 cas, c'est à l'application de fournir la valeur pour chacun des champs.

Ce que l'on va faire

Afin d'éviter que cela soit aux développeurs de l'application de fournir les valeurs pour les champs d'audit (et pour éviter les oublis), nous allons faire en sorte que les valeurs soient définies par EF Core au moment de la sauvegarde de l'entité concernée.

Pour cela, nous allons utiliser :

  • un backing field pour la date de création. Le code des entités sera personnalisé.
  • un ValueConverter pour la date de modification. Le code du DbContext sera personnalisé.

Personnalisation des entités

Dans le cas de la date de création, ce que nous souhaitons c'est que la date de création :

  • soit provisionnée automatiquement avec la valeur DateTime.Now à la création d'une nouvelle entité.
  • ne soit pas modifiable.

Nous allons ajouter un backing field _createdDate dans les entités concernées tout en modifiant la propriété CreatedDate pour ne garder que le getter.

Précédemment, nous avons déclaré un design-time service au moyen de la classe MyStoreEntityTypeGenerator implémentant l'interface ICSharpEntityTypeGenerator.

Etant donné que l'on souhaite personnaliser le code des entités, notre classe MyStoreEntityTypeGenerator va hériter de CSharpEntityTypeGenerator, elle même implémentant l'interface ICSharpEntityTypeGenerator.

De la même manière que pour la personnalisation du DbContext, il faudra :

  • écrire un constructeur : la classe CSharpEntityTypeGenerator ne définissant pas de constructeur sans paramètre.
  • surcharger la méthode WriteCode qui est en charge de la génération du code des entités.

Voici le code de la classe MyStoreEntityTypeGenerator, que nous allons détailler ensuite :

using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using System;
using System.Text.RegularExpressions;

namespace EFCoreDBFirst.MyStore
{
    public class MyStoreEntityTypeGenerator : CSharpEntityTypeGenerator
    {
        public MyStoreEntityTypeGenerator(ICSharpHelper cSharpHelper) : base(cSharpHelper)
        {
        }

        public override string WriteCode(IEntityType entityType, string @namespace, bool useDataAnnotations)
        {
            var tabSpace = "    "; // 4 spaces tab
            var nl = Environment.NewLine;
            var code = base.WriteCode(entityType, @namespace, useDataAnnotations);

            var createdDatePattern = @"DateTime CreatedDate";
            var createdDateRegex = new Regex(createdDatePattern, RegexOptions.Compiled);
            if (createdDateRegex.IsMatch(code))
            {
                // Backing field insertion
                var entityConstructorPattern = $@"(public {entityType.Name}\(\))";
                var entityConstructorRegex = new Regex(entityConstructorPattern, RegexOptions.Compiled);
                var backingFieldCode = "private DateTime _createdDate = DateTime.Now;";
                code = entityConstructorRegex.Replace(code, $"{backingFieldCode}{nl}{nl}{tabSpace}{tabSpace}$1");

                // CreatedDate property modification
                var createdDatePropertyPattern = @"(public DateTime CreatedDate \{ get; set; \})";
                var createdDatePropertyRegex = new Regex(createdDatePropertyPattern, RegexOptions.Compiled);
                var createdDateCode = $"public DateTime CreatedDate{nl}"
                                    + $"{tabSpace}{tabSpace}{{{nl}"
                                    + $"{tabSpace}{tabSpace}{tabSpace}get{nl}"
                                    + $"{tabSpace}{tabSpace}{tabSpace}{{{nl}"
                                    + $"{tabSpace}{tabSpace}{tabSpace}{tabSpace}return _createdDate;{nl}"
                                    + $"{tabSpace}{tabSpace}{tabSpace}}}{nl}"
                                    + $"{tabSpace}{tabSpace}}}";
                code = createdDatePropertyRegex.Replace(code, createdDateCode);
            }

            return code;
        }
    }
}

Le constructeur ne servant que de passe-plat, il n'y a pas grand chose à dire : il se contente de passer son argument au constructeur de sa classe de base CSharpEntityTypeGenerator.

Concernant la méthode WriteCode, elle fonctionne sur le même principe que celle que l'on a vu lors de la génération du DbContext : elle renvoie le code généré sous la forme d'une chaine de caractère ce qui oblige à du search and replace pour modifier les parties qui nous intéressent.

Cependant, elle prend des paramètres légèrement différents :

  • IEntityType entityType contient les informations concernant l'entité courante.
  • string @namespace contient le namespace dans lequel va être généré l'entité.
  • bool useDataAnnotations permet de savoir si la commande scaffold a été appelée avec l'option -d | --data-annotations.

Dans notre cas d'exemple, nous commençons par vérifier que la propriété CreatedDate existe sur l'entité en cours de génération. Ensuite nous ajoutons, le backing field avant le constructeur de l'entité et ensuite nous modifions la propriété CreatedDate pour qu'elle utilise le backing field et qu'elle n'expose plus de setter.

Le code généré de l'entité Product devrait ressembler à celui-ci :

using System;
using System.Collections.Generic;

namespace EFCoreDBFirst.MyStore.DAL
{
    public partial class Product
    {
        private DateTime _createdDate = DateTime.Now;

        public Product()
        {
            ProductView = new HashSet<ProductView>();
        }

        public int Id { get; set; }
        public int CategoryId { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public int Stock { get; set; }
        public DateTime CreatedDate
        {
            get
            {
                return _createdDate;
            }
        }
        public DateTime ModifiedDate { get; set; }

        public Category Category { get; set; }
        public ICollection<ProductView> ProductView { get; set; }
    }
}

Concernant l'utilisation d'un backing field, nous faisons en sorte que la propriété CreatedDate ne soit accessible qu'en lecture. La valeur du backing field étant assignée à l'instanciation de la classe. EFCore écrase cette valeur lorsqu'il charge une entité depuis la base de données. Pour plus de précisions sur les backing fields, vous pouvez consulter cette page : Backing Fields - EF Core | Microsoft Docs

Personnalisation du DbContext

Dans le cas de la date de modification, nous souhaitons que la propriété :

  • soit provisionnée automatiquement à chaque fois que l'on sauve l'entité en base de donneés.
  • ne change pas de valeur à la lecture.

Pour cela, nous allons utiliser un ValueConverter nous permettant de gérer ce qui sera chargé et ce qui sera sauvegardé en base de données.

Voici à quoi devrait ressembler notre classe MyStoreDbContextGenerator après avoir ajouter la modification pour le ValueConverter :

using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Scaffolding;
using Microsoft.EntityFrameworkCore.Scaffolding.Internal;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace EFCoreDBFirst.MyStore
{
    public class MyStoreDbContextGenerator : CSharpDbContextGenerator
    {
        public MyStoreDbContextGenerator(
#pragma warning disable CS0618 // Type or member is obsolete
            IEnumerable<IScaffoldingProviderCodeGenerator> legacyProviderCodeGenerators,
#pragma warning restore CS0618 // Type or member is obsolete
            IEnumerable<IProviderConfigurationCodeGenerator> providerCodeGenerators,
            IAnnotationCodeGenerator annotationCodeGenerator,
            ICSharpHelper cSharpHelper
        ) : base(legacyProviderCodeGenerators, providerCodeGenerators, annotationCodeGenerator, cSharpHelper)
        {
        }

        public override string WriteCode(IModel model, string @namespace, string contextName, string connectionString,
            bool useDataAnnotations, bool suppressConnectionStringWarning)
        {
            var tabSpace = "    "; // 4 spaces tab
            var nl = Environment.NewLine;
            var code = base.WriteCode(model, @namespace, contextName, connectionString, useDataAnnotations, suppressConnectionStringWarning);

            // ConnectionString deletion
            var connectionStringPattern = @"(protected override void OnConfiguring\(DbContextOptionsBuilder optionsBuilder\)\r\n\s*\{)((.|\n)*)(\r\n\s*\}(\r\n){2})";
            var connectionStringRegex = new Regex(connectionStringPattern, RegexOptions.Compiled);
            code = connectionStringRegex.Replace(code, "$1$4");

            // ValueConverter insertion
            var valueConverterPattern = @"(protected override void OnModelCreating\(ModelBuilder modelBuilder\)\r\n\s*\{)(\s*modelBuilder\.Entity)";
            var valueConverterRegex = new Regex(valueConverterPattern, RegexOptions.Compiled);
            var valueConverterCode = $"{tabSpace}{tabSpace}{tabSpace}var converter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>({nl}"
                                   + $"{tabSpace}{tabSpace}{tabSpace}{tabSpace}v => DateTime.Now,{nl}"
                                   + $"{tabSpace}{tabSpace}{tabSpace}{tabSpace}v => v{nl}"
                                   + $"{tabSpace}{tabSpace}{tabSpace});{nl}";
            code = valueConverterRegex.Replace(code, $"$1{nl}{valueConverterCode}$2");

            // ValueConverter usage
            var modifiedDatePattern = @"(ModifiedDate\)\.HasColumnType\(""datetime""\))(;\r\n)";
            var modifiedDateRegex = new Regex(modifiedDatePattern, RegexOptions.Compiled);
            code = modifiedDateRegex.Replace(code, $"$1.HasConversion(converter)$2");

            return code;
        }
    }
}

Après le code permettant la suppression de la connectionstring, nous avons :

  • le code insérant la déclaration du ValueConverter.
  • le code permettant l'utilisation du ValueConverter sur toutes les entités contenant une propriété ModifiedDate.

Le code généré du DbContext devrait ressembler à celui-ci :

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;

namespace EFCoreDBFirst.MyStore.DAL
{
    public partial class MyStoreContext : DbContext
    {
        public MyStoreContext()
        {
        }

        public MyStoreContext(DbContextOptions<MyStoreContext> options)
            : base(options)
        {
        }

        public virtual DbSet<Category> Category { get; set; }
        public virtual DbSet<Product> Product { get; set; }
        public virtual DbSet<ProductView> ProductView { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            var converter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>(
                v => DateTime.Now,
                v => v
            );

            modelBuilder.Entity<Category>(entity =>
            {
                entity.Property(e => e.Description).HasMaxLength(255);

                entity.Property(e => e.Name)
                    .IsRequired()
                    .HasMaxLength(50);
            });

            modelBuilder.Entity<Product>(entity =>
            {
                entity.Property(e => e.CreatedDate).HasColumnType("datetime");

                entity.Property(e => e.Description).IsRequired();

                entity.Property(e => e.ModifiedDate).HasColumnType("datetime").HasConversion(converter);

                entity.Property(e => e.Name)
                    .IsRequired()
                    .HasMaxLength(128);

                entity.Property(e => e.Price).HasColumnType("decimal(18, 3)");

                entity.HasOne(d => d.Category)
                    .WithMany(p => p.Product)
                    .HasForeignKey(d => d.CategoryId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK_Product_Category_Id");
            });

            modelBuilder.Entity<ProductView>(entity =>
            {
                entity.ToTable("ProductView", "stats");

                entity.HasOne(d => d.Product)
                    .WithMany(p => p.ProductView)
                    .HasForeignKey(d => d.ProductId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK_ProductView_Product_Id");
            });
        }
    }
}

Nous pouvons constater que le ValueConverter utilise :

  • DateTime.Now dans l'expression utilisée lors de la sauvegarde des données.
  • la valeur présente en base de données, sans conversion, lors de la lecture des données.

Pour plus d'informations que les ValueConverters, vous pouvez consulter ce lien : Value Conversions - EF Core | Microsoft Docs

Jérémy Larrieu

Titulaire d'une licence pro Systèmes Informatiques et Logiciels, Jérémy a d'abord travaillé en tant que développeur web freelance, puis développeur .NET dans une ESN avant de rejoindre SoftFluent.

Profil de l'auteur