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éthodeOnConfiguring
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 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
{
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é leDbContext
.string contextName
contient le nom duDbContext
: soit le nom par défaut soit celui que l’on aura choisi en utilisant l’optionc
(ou--context
).bool useDataAnnotations
permet de savoir si la commandescaffold
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 duDbContext
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 commandescaffold
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 ValueConverter
s, vous pouvez consulter ce lien : Value Conversions – EF Core | Microsoft Docs