Implémenter un modèle d'entités
Introduction
Après avoir présenté les concepts généraux d'Entity Framework Core (EF Core), nous allons construire un modèle d'entités. Ce modèle constitue le cœur de l'application : il définit les propriétés et le comportement des objets métiers qui seront chargés depuis la base de données et persistés au sein de celle-ci.
Grâce au mécanisme de mapping proposé par EF Core, il n'est plus nécessaire d'écrire les requêtes SQL dans le code C#. Il se charge d'établir la correspondance entre les classes C# et les tables de la base de données. Toutefois, connaître le langage SQL nous aidera à optimiser les requêtes Linq to Entities que nous écrirons.
Dans cet article, nous verrons comment implémenter une entité, la mapper sur une table de manière déclarative (à l'aide des attributs de mapping) puis de manière impérative (à l'aide de Fluent API), avant de l'intégrer dans un contexte de données afin de préparer les futures opérations de lecture et d'écriture.
Le modèle objet
Le modèle objet constitue le cœur d'une application utilisant EF Core. Il est composé d'un ensemble de classes, appelées entités, qui représentent les données manipulées par l'application. Dans la plupart des cas, chaque entité est associée à une table de la base de données, et ses propriétés correspondent aux colonnes de cette table.
Par exemple, le script SQL suivant crée une table Customer dans une base de données SQL Server :
CREATE TABLE dbo.Customer
(
Id bigint IDENTITY(1,1) NOT NULL,
FirstName nvarchar(100) NOT NULL,
LastName nvarchar(100) NOT NULL,
Email nvarchar(255) NOT NULL,
BirthDate date NULL,
IsActive bit NOT NULL,
CreatedDate datetime2(7) NOT NULL,
CONSTRAINT PK_Customer PRIMARY KEY CLUSTERED (Id),
CONSTRAINT UQ_Customer_Email UNIQUE (Email)
);
Cette table possède une clé primaire nommée Id définie comme une colonne IDENTITY. Sa valeur est générée automatiquement par SQL Server à chaque insertion d'une nouvelle ligne dans la table.
Le champ Email possède une contrainte de clé unique afin que deux utilisateurs n'aient pas la même adresse e-mail.
Configurer EF Core
Pour utiliser une base de données SQL Server dans notre application, il est nécessaire d'ajouter le package NuGet correspondant au fournisseur SQL Server :
Implémenter une entité
Dans le cas le plus courant, une table est représentée par une classe C# appelée entité. Les colonnes de la table définissent les propriétés de cette classe, tandis que chaque ligne de la table correspond à une instance de l'entité.
EF Core permet également de représenter une hiérarchie de tables ou d'entités à l'aide de l'héritage. Nous reviendrons sur ce mécanisme dans un article dédié.
La table Customer peut ainsi être représentée par la classe suivante :
public class Customer
{
public long Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateOnly? BirthDate { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedDate { get; set; }
public Customer(string firstName, string lastName, string email, DateOnly? birthDate, bool isActive)
{
FirstName = firstName;
LastName = lastName;
Email = email;
BirthDate = birthDate;
IsActive = isActive;
CreatedDate = DateTime.UtcNow;
}
}
Chaque propriété utilise un type .NET compatible avec le type SQL correspondant. Ainsi, le type bigint est implémenté par un long, nvarchar par string, bit par bool et datetime2 par DateTime. La colonne BirthDate étant nullable, elle est représentée par DateOnly? afin d'autoriser la nullité de la valeur.
Mapper une entité de manière déclarative
L'implémentation déclarative consiste à associer une entité et ses propriétés à une table et ses champs de la base de données, en utilisant des attributs de mapping. Voici un exemple :
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
[Table("Customer")]
[Index(nameof(Email), Name = "UQ_Customer_Email",IsUnique = true)]
public class Customer
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("Id")]
public long Id { get; set; }
[Required]
[MaxLength(100)]
[Column("FirstName")]
public string FirstName { get; set; }
[Required]
[MaxLength(100)]
[Column("LastName")]
public string LastName { get; set; }
[Required]
[MaxLength(255)]
[Column("Email")]
public string Email { get; set; }
[Column("BirthDate")]
public DateOnly? BirthDate { get; set; }
[Required]
[Column("IsActive")]
public bool IsActive { get; set; }
[Required]
[Column("CreatedDate")]
public DateTime CreatedDate { get; set; }
public Customer(string firstName, string lastName, string email, DateOnly? birthDate, bool isActive)
{
FirstName = firstName;
LastName = lastName;
Email = email;
BirthDate = birthDate;
IsActive = isActive;
CreatedDate = DateTime.UtcNow;
}
}
Les principaux attributs utilisés sont :
-
[Table] : indique la table et le schéma SQL associés à l'entité.
-
[Key] : désigne la clé primaire.
-
[DatabaseGenerated] : précise que la valeur est générée automatiquement par SQL Server (IDENTITY).
-
[Required] : indique qu'une valeur est obligatoire.
-
[MaxLength] : limite la longueur maximale d'une chaîne de caractères.
Dans cet exemple, nous aurions pu ne pas utiliser l'attribut Column, car les conventions d'EF Core suffisent à déterminer leur mapping.
Mapper une entité de manière impérative
Une autre approche consiste à définir le mapping à l'extérieur de l'entité, en utilisant Fluent API. Contrairement aux attributs de mapping, les correspondances ne sont plus portées par la classe métier, mais sont centralisées dans le contexte de données ou dans une classe de configuration dédiée.
Cette approche présente plusieurs avantages. Elle permet de conserver des entités indépendantes d'EF Core, de centraliser l'ensemble de la configuration du modèle et d'exprimer des règles de mapping plus avancées que celles offertes par les attributs.
Dans ce cas, la classe Customer redevient alors une simple classe C# :
public class Customer
{
public long Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateOnly? BirthDate { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedDate { get; set; }
public Customer(string firstName, string lastName, string email, DateOnly? birthDate, bool isActive)
{
FirstName = firstName;
LastName = lastName;
Email = email;
BirthDate = birthDate;
IsActive = isActive;
CreatedDate = DateTime.UtcNow;
}
}
Le mapping est ensuite implémenté dans la méthode OnModelCreating du contexte de données (qui sera présenté ci-dessous) :
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(entity =>
{
entity.ToTable("Customer");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.FirstName)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.LastName)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.Email)
.IsRequired()
.HasMaxLength(255);
entity.HasIndex(e => e.Email)
.IsUnique()
.HasDatabaseName("UQ_Customer_Email");
entity.Property(e => e.BirthDate);
entity.Property(e => e.IsActive)
.IsRequired();
entity.Property(e => e.CreatedDate)
.IsRequired();
});
}
Les principales méthodes utilisées sont :
-
ToTable : associe l'entité à une table de la base de données.
-
HasKey : définit la clé primaire.
-
Property : configure une propriété de l'entité.
-
ValueGeneratedOnAdd : indique que la valeur est générée lors de l'insertion.
-
IsRequired : rend la propriété obligatoire.
-
HasMaxLength : définit la longueur maximale d'une chaîne de caractères.
La Fluent API permet également de configurer des mappings plus complexes, comme les relations entre entités, les index, les contraintes, les valeurs par défaut, les conversions de types ou encore les hiérarchies d'héritage. Nous reviendrons sur ces fonctionnalités dans les prochains articles.
Aussi, avec les évolutions d'EF Core, il est recommandé d'implémenter le mapping de chaque entité dans une classe dédiée implémentant l'interface IEntityTypeConfiguration<T> (ou T est l'entité). Cette approche améliore la lisibilité du contexte de données et facilite la maintenance lorsque le nombre d'entités devient important.
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.ToTable("Customer");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id)
.ValueGeneratedOnAdd();
builder.Property(e => e.FirstName)
.IsRequired()
.HasMaxLength(100);
// ...
}
}
Et dans le contexte de données (présentée ci-dessous), redéfinir la méthode OnModelCreating, afin de rechercher et enregistrer les classes de configuration du mapping :
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(ApplicationDbContext).Assembly);
}
Cette implémentation est plus représentative des bonnes pratiques actuelles avec EF Core.
Le contexte de données
Le contexte de données est une classe héritant de la classe DbContext. Chaque instance représente une "session de travail" entre l'application et la base de données. Il expose l'ensemble des entités manipulées par l'application au travers de propriétés de type DbSet<T>.
public class ApplicationDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Implémentation du mapping
}
}
Chaque propriété de type DbSet<T> représente une table de la base de données et permet d'effectuer les opérations de lecture et d'écriture.
Configurer la connexion à la base de données
La chaîne de connexion est ensuite déclarée dans le fichier appsettings.json :
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\sqlexpress;Database=Demo;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
Enfin, le contexte est enregistré dans le conteneur d'injection de dépendances :
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"));
});
À partir de ce moment, ApplicationDbContext peut être injecté dans les différents services de l'application pour leur permettre à d'accéder à la base de données.
Manipuler les données
Ajouter un client
Une fois le mapping et le contexte de données implémentés, les opérations CRUD sont aussi simples à implémenter. Voici un exemple avec l'ajout d'un utilisateur :
var customer = new Customer("James", "RAVAILLE", "jra@algowin.fr", new DateOnly(1978, 4, 18), true);
context.Customers.Add(customer);
await context.SaveChangesAsync();
Après l'appel à la méthode SaveChangesAsync(), la propriété Id de l'objet customer contient automatiquement la valeur générée par SQL Server.
Obtenir un client
Cette requête LINQ to Entities permet d'obtenir un client à partir de son adresse e-mail :
Modifier un client
Cette instruction permet de désactiver un client :
Supprimer un client
Cette instruction permet de supprimer un client :
Le rôle des méthodes SaveChanges() / SaveChangesAsync()
L'utilisation des méthodes Add(), Remove() et la modification d'une propriété n'exécutent pas immédiatement une requête SQL. Le contexte de données mémorise l'ensemble des changements grâce au Change Tracker (mécanisme de suivi des modifications - nous le détaillerons dans un prochain article). Ce n'est qu'au moment de l'appel à SaveChanges() ou SaveChangesAsync() qu'EF Core :
-
Détecte les modifications effectuées
-
Génère les instructions SQL correspondantes (INSERT, UPDATE ou DELETE)
-
Les exécute au sein d'une transaction
Ce fonctionnement permet de regrouper plusieurs opérations sur les données dans une même transaction. Ainsi, soit toutes les opérations sont exécutées avec succès, soit aucune ne l'est.