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 :
- Visite guidée
- Les outils en ligne de commande
- Personnaliser le code généré( cet article)
- 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 duDbContext. C'est quelque peu problématique de conserver la connectionstring tel quel dans notre code : nous allons donc faire en sorte que la méthodeOnConfiguringsoit 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
À 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 MyStoreDbContextGeneratorimplé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
CSharpDbContextGeneratorne définissant pas de constructeur sans paramètre. - surcharger la méthode
WriteCodequi est en charge de la génération du code duDbContext.
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
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 modelcontient 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 @namespacecontient le namespace dans lequel va être généré leDbContext.string contextNamecontient le nom duDbContext: soit le nom par défaut soit celui que l'on aura choisi en utilisant l'optionc(ou--context).bool useDataAnnotationspermet de savoir si la commandescaffolda été appelée avec l'option-d | --data-annotations.bool suppressConnectionStringWarningpermet 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
DateTimenon nullable et sans valeur par défaut. - La date de modification est un champ de type
DateTimenon 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
ValueConverterpour la date de modification. Le code duDbContextsera 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
CSharpEntityTypeGeneratorne définissant pas de constructeur sans paramètre. - surcharger la méthode
WriteCodequi 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
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 entityTypecontient les informations concernant l'entité courante.string @namespacecontient le namespace dans lequel va être généré l'entité.bool useDataAnnotationspermet de savoir si la commandescaffolda é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
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({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
ValueConvertersur 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.Nowdans 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

