Cours 16 - Relations et DTOs
💍 Relations
Comme on utilise Entity Framework, nos classes doivent respecter certaines contraintes.
- Posséder un
idqui servira de clé primaire :
public class Item{
public int Id { get; set; } // Indispensable 😩
public string Name { get; set; } = null!;
...
}
- Si une classe possède des relations, précéder les propriétés de navigation du mot-clé
virtual:
public class NPC{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Item> SoldItems { get; set; } = new List<Item>(); // virtual !
...
}
Les « propriétés de navigation » sont des propriétés qui permettent de concrétiser la relation entre deux classes / entités.
C'est généralement une simple référence vers une autre classe (ex : public virtual Item Item { get; set; }) ou une liste
de références vers une autre classe. (Ex : public virtual List<Item> Items { get; set; })
Pour pouvoir profiter du 🦥 Lazy Loading, vous êtes invités à installer le package Proxies dans la dernière version 8.X.X pour vous simplifier la vie lors du chargement des données depuis la base de données !
Le code dans Program.cs devra être légèrement modifier :
builder.Services.AddDbContext<serveur15Context>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("serveur15Context") ?? throw new InvalidOperationException("Connection string 'serveur15Context' not found."));
options.UseLazyLoadingProxies(); // On ajoute ceci !
});
Les prochaines sections abordent des exemples pour chaque type de relation possible.
🍒 One-To-One
Un User possède (optionnellement) une Subscription.
public class User{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual Subscription? Subscription { get; set; } // Propriété de navigation
}
Le ? après le type Subscription permet d'indiquer que la propriété est optionnelle et donc peut être null.
Une Subscription appartient forcément à un User.
public class Subscription{
public int Id { get; set; }
public int Points { get; set; }
public DateOnly StartDate { get; set; }
// Annotation nécessaire pour que EntityFramework sache dans quelle table placer la FK.
// « User » est le nom de la classe qui complète la relation.
[ForeignKey(nameof(User))]
public virtual User User { get; set; } = null!; // Propriété de navigation
}
🍇 One-To-Many
Un Post peut avoir plusieurs Comment.
public class Post{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual List<Comment> Comments { get; set; } = new List<Comment>(); // Propriété de navigation (liste !)
}
Un Comment est associé à un seul Post.
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual Post Post { get; set; } = null!; // Propriété de navigation
}
🍣 Many-To-Many
Un Ingredient peut faire partie de plusieurs Recipe.
public class Ingredient{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Recipe> Recipes { get; set; } = new List<Recipe>(); // Propriété de navigation
}
Une Recipe peut contenir plusieurs Ingredient.
public class Recipe{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Ingredient> Ingredients { get; set; } = new List<Ingredient>(); // Propriété de navigation
}
😵 Double relation
Lorsque (par exemple) deux classes partagent deux relations, il faut aider Entity Framework à déterminer quelles propriétés de navigation correspondent ensemble.
Disons qu'on a les classes Parent et Child. Un Parent a plusieurs Child, bien entendu, mais on aimerait aussi noter son Child préféré. Il existe donc deux relations entre Parent et Child :
- Une relation Many-To-Many : Un
Parentpeut avoir plusieursChild, unChildappartient à plusieursParent. - Une relation One-To-Many : Un
Parenta un seulChildpréféré, unChildpeut être l'enfant préféré de plusieursParent.
public class Parent{
public int Id { get; set; }
public string Name { get; set; } = null!;
[InverseProperty("Parents")]
public virtual List<Child> Children { get; set; } = new List<Child>();
[InverseProperty("FavoriteChildOf")]
public virtual Child FavoriteChild { get; set; } = null!;
}
public class Child{
public int Id { get; set; }
public string Name { get; set; } = null!;
[InverseProperty("Children")]
public virtual List<Parent> Parents { get; set; } = new List<Parent>();
[InverseProperty("FavoriteChild")]
public virtual List<Parent> FavoriteChildOf { get; set; } = new List<Parent>();
}
On remarque l'usage de l'annotation [InverseProperty("NomDeLaPropriétéInverse")] au-dessus des propriétés de navigation. Cette annotation sert à désigner quelle propriété de l'autre classe est associée à celle précédée de cette annotation. Sans cette annotation, Entity Framework n'aurait pas su si Parents (dans la classe Child) était associée à Children ou bien à FavoriteChild. (Dans la classe Parent)
Bien entendu, d'un point de vue sémantique 🧐, les noms des propriétés peuvent nous aider, nous, humains, à comprendre quelles propriétés s'agencent ensemble.
🌀 Objets JSON infinis
Lorsqu'une action du serveur retournera un objet qui possède une relation avec un autre objet, cela générera un objet JSON infini. Il faut donc utiliser l'annotation [JsonIgnore] stratégiquement pour éviter les cycles dans les objets JSON. Un exemple est abordé ci-dessous.
Reprenons l'exemple One-To-Many suivant :
« Un Post peut avoir plusieurs Comment. Un Comment est associé à un seul Post. »
- Classe Post
- Classe Comment
public class Post{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual List<Comment> Comments { get; set; } = new List<Comment>(); // Propriété de navigation (liste !)
}
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual Post Post { get; set; } = null!; // Propriété de navigation
}
Si une action quelconque dans un contrôleur retourne un Post qui possède un Comment, à cause des propriétés de navigation Comments et Post, voici l'objet JSON qui sera généré :
{
id : 1,
text : "Salut les amis",
comments : [
{
id : 1,
text : "Ça va ?"
post : {
id : 1,
text : "Salut les amis",
comments : [
{
id : 1,
text : "Ça va ?"
post : {
id : 1,
text : "Salut les amis",
comments : [
{
id : 1,
text : "Ça va ?"
post : {
// etc ... à l'infini 💀
}
}
]
}
}
]
}
}
]
}
Comment briser le cycle ? En utilisant l'annotation [JsonIgnore] au-dessus de l'une des deux propriétés de navigation. (Ou bien les deux... mais à ce moment impossible d'avoir un post ET ses comment simultanément)
Dans cette situation, on pourrait mettre l'annotation dans la classe Comment :
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; // Assurez-vous d'utiliser cette librairie et non Newtonsoft !
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
[JsonIgnore]
public virtual Post Post { get; set; } // Propriété de navigation
}
Désormais, retourner le JSON d'un Post impliquera aussi ses Comment, mais un Comment retourné ne contiendra pas son Post.
📦 Data-Transfer Objects
Parfois les données qu'on souhaite envoyer au serveur ne correspondent pas exactement à la structure d'un modèle du serveur. Dans ce cas, l'action Post auto-générée lors de la création d'un contrôleur ne convient pas.
Quelques exemples :
- On souhaite envoyer et créer deux objets simultanément. (Sachant qu'il est impossible d'envoyer deux
bodydans la requêtePost) - On souhaite envoyer un objet et une liste d'
idsqui représente ses relations. - On souhaite envoyer un objet et un fichier image.
Reprenons l'exemple Many-To-Many suivant :
« Un Ingredient peut faire partie de plusieurs Recipe. Une Recipe peut contenir plusieurs Ingredient. »
- Classe Ingredient
- Classe Recipe
public class Ingredient{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Recipe> Recipes { get; set; } = new List<Recipe>(); // Propriété de navigation
}
public class Recipe{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Ingredient> Ingredients { get; set; } = new List<Ingredient>(); // Propriété de navigation
}
Lorsqu'on souhaiter créer (Post) une Recipe, ce qu'on doit envoyer au serveur, c'est ... :
- Un
string(le nameName) - Une
List<int>(l'idde chaqueIngredientfaisant partie de cetteRecipe)
Or, aucun modèle ne correspond à la combinaison de ces deux types. La solution est donc de créer une autre classe qui servira exclusivement à transférer des données entre le client et le serveur, d'où le nom Data Transfer Object.
public class RecipeDTO
{
public string Name { get; set; } = null!;
public List<int> IngredientIds { get; set;} = new List<int>();
}
Côté serveur, on aura une action Post qui recevra un objet de ce type :
[HttpPost]
public async Task<ActionResult> PostRecipe(RecipeDTO recipeDTO){
...
}
Côté client, on lancera une requête Post dont le corps sera un objet avec une structure identique ⚠ :
async function createRecipe(ids : number[], recipeName : string){
// Ceci est un « objet anonyme », mais comme sa structure est identique au DTO, ça fonctionne.
const recipeDTO = {
name : recipeName,
ingredientIds : ids
}
const x = await axios.post("https://localhost:6969/api/Recipes/PostRecipe", recipeDTO);
console.log(x.data);
...
}
Pourquoi avoir utilisé un objet anonyme plutôt qu'avoir créé un modèle export class RecipeDTO ... ? Créer un modèle aurait été tout à fait acceptable. (C'est même plus propre !) Cela dit, généralement, un DTO risque d'avoir une utilité très limitée (utilisé à un ou deux endroits ?) alors se contenter d'un objet anonyme créé sur le pouce est acceptable. Si par contre on finit par utiliser le RecipeDTO à plusieurs endroits, vaut mieux en faire un modèle !
Attention aux erreurs avec le code 400 ! Si la console vous affiche 🐞 error 400, vous avez affaire à une erreur de validation : les données que vous avez envoyées au serveur ne correspondent pas à ce que l'action du serveur voulait obtenir.
Exemples de causes :
- L'objet envoyé semble bon, mais une de ses propriétés est vide / manquante.
- Vous avez envoyé un nombre à virgule alors que le type attendu sur le serveur est
int. - Vous n'avez rien envoyé du tout. (Le body est
null)
💋 Gestion de relations
🥚 Création
🍇 One-To-Many
Reprenons l'exemple One-To-Many suivant :
« Un Post peut avoir plusieurs Comment. Un Comment est associé à un seul Post. »
- Classe Post
- Classe Comment
public class Post{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual List<Comment> Comments { get; set; } = new List<Comment>();
}
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual Post Post { get; set; } = null!;
}
Lorsque nous allons créer un Post, il n'y a aucun défi : la liste Comments peut être vide initialement, on doit juste envoyer au serveur un Text. (et optionnellement, l'id 0)
Par contre, lorsque nous allons créer un Comment, il faudra absolument ⛔ indiquer au serveur à quel Post il appartient ! Il suffira d'envoyer au serveur un objet qui contient :
- Le
Text(string) duComment. - L'
Id(int) duPostauquel il appartient.
Côté client, voici comment on pourrait envoyer les données du nouveau Comment :
async function postComment(id : number, commentText : string){
let commentDTO = {
text : commentText, // Texte du comment
postId : id // Id du post auquel le comment appartient
};
let x = await axios.post("https://localhost:6969/api/Comments/PostComment", commentDTO);
console.log(x.data);
}
Côté serveur, voici comment nous pourrions gérer la création du Comment pour l'ajouter à la base de données :
[HttpPost]
public async Task<ActionResult<Comment>> PostComment(CommentDTO commentDTO)
{
// On essaye de trouver le Post qui possède l'id fourni
Post? post = await _context.Post.FindAsync(commentDTO.PostId);
// Aucun Post n'a été trouvé avec cet id ? On s'arrête ici, on ne veut pas créer un Comment incohérent
if (post == null) return NotFound(new {Message = "Ce post n'existe pas. Il a peut-être été supprimé ?"});
// On crée le Comment en se servant du Post trouvé
Comment comment = new Comment { Id = 0, Text = commentDTO.Text, Post = post };
// On l'ajoute à la base de données
_context.Comment.Add(comment);
await _context.SaveChangesAsync();
// On retourne le nouveau commentaire créé à l'application client
// On aurait aussi pu faire « return NoContent() », au choix !
return Ok(comment);
}
Si nous n'avions pas associer le Post trouvé au nouveau Comment créé en faisant Post = post, il n'y aurait eu
aucun lien entre le Post et le Commentdans la base de données. Cette opération est essentielle.
Pour 🍒 One-To-One, c'est généralement le même processus. Il y aura un objet indépendant qu'on peut créer sans l'autre initialement, puis il y aura le deuxième objet (dépendant) qu'on devra créer en fournissant l'id du premier.
Dans la rare situation où les deux objets doivent être créés simultanément, il faudra créer un DTO qui contient les données des deux objets pour les créer simultanément dans la même action Post.
🍣 Many-To-Many
Le cas Many-To-Many a un avantage : il est possible de créer les deux objets dans n'importe quel ordre, puisque leur propriété de navigation est une liste qui a très bien le droit d'être vide initialement.
Reprenons l'exemple Many-To-Many suivant :
« Un Ingredient peut faire partie de plusieurs Recipe. Une Recipe peut contenir plusieurs Ingredient. »
- Classe Ingredient
- Classe Recipe
public class Ingredient{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Recipe> Recipes { get; set; } = new List<Recipe>(); // Propriété de navigation
}
public class Recipe{
public int Id { get; set; }
public string Name { get; set; } = null!;
public virtual List<Ingredient> Ingredients { get; set; } = new List<Ingredient>(); // Propriété de navigation
}
On peut très bien ajouter des Ingredient et des Recipe dans la base de données sans qu'ils ne soient liés à d'autres objets initialement.
Voici toutefois quelques approches qui pourraient être intéressantes pour concrétiser la relation entre ces deux entités :
- (🥚
Post) Lorsqu'on crée uneRecipe, on envoie également une liste d'ids d'ingrédients qui serviront à indiquer au serveur quelsIngredientsont inclus dans laRecipe. - (✏
Put) Après avoir créé uneRecipe, on peut, par la suite, indiquer quelsIngredientlui sont associés en envoyant au serveur l'id de la recette ET la liste d'ids des ingrédients. - (✏
Put) Après avoir créé uneRecipe, on peut, par la suite, indiquer, un à la fois, unIngredientqui en fait partie en envoyant au serveur l'id de la recette et l'id d'un ingrédient.
Voici, par exemple, pour la première de ces trois options, la solution en se servant d'un DTO :
public class RecipeDTO
{
public string Name { get; set; } = null!;
public List<int> IngredientIds { get; set;} = new List<int>();
}
Côté client, voici comment on pourrait envoyer les données de la nouvelle Recipe :
async function postRecipe(recipeName : string, ids : number[]){
const recipeDTO = {
name : recipeName, // Nom de la Recipe
ingredientIds : ids // Ids des ingrédients
};
const x = await axios.post("https://localhost:6969/api/Recipes/PostRecipe", recipeDTO);
console.log(x.data);
}
Côté serveur, on aura une action Post qui recevra le DTO et créera la Recipe :
[HttpPost]
public async Task<ActionResult> PostRecipe(RecipeDTO recipeDTO){
// On préparer une liste pour tous les ingrédients de la recette
List<Ingredient> ingredients = new List<Ingredient>();
// On tente de trouver chacun des ingrédients avec les ids fournis
foreach(int id in recipeDTO.IngredientIds){
Ingredient? i = await _context.Ingredient.FindAsync(id);
// Ingrédient inexistant ? On arrête tout ! On ne veut pas créer une recette incohérente.
if(i == null) return NotFound(new { Message = "Aucun ingrédient n'existe avec l'id " + id + "."});
ingredients.Add(i);
}
// On crée la Recipe en se servant de la liste d'ingrédients préaprées
Recipe recipe = new Recipe { Id = 0, Name = recipeDTO.Name, Ingredients = ingredients };
// On l'ajoute à la base de données
_context.Recipe.Add(recipe);
await _context.SaveChangesAsync();
// On retourne la nouvelle Recipe créée à l'application client
// On aurait aussi pu faire « return NoContent() », au choix !
return Ok(recipe);
}
💣 Suppression
La suppression peut sembler évidente, mais il faut faire attention aux clés étrangères 🔑😩 !
Reprenons l'exemple One-To-Many suivant :
« Un Post peut avoir plusieurs Comment. Un Comment est associé à un seul Post. »
- Classe Post
- Classe Comment
public class Post{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual List<Comment> Comments { get; set; } = new List<Comment>();
}
public class Comment{
public int Id { get; set; }
public string Text { get; set; } = null!;
public virtual Post Post { get; set; } = null!;
}
Si on tente de supprimer un Post sans supprimer ses Comment, la base de données lancera une erreur ! ⛔
✅ La solution : supprimer tous les Comment d'un Post lorsqu'on le supprime :
[HttpDelete("{id}")]
public async Task<IActionResult> DeletePost(int id)
{
// Chercher le post avec l'id fourni
Post? post = await _context.Post.FindAsync(id);
// Il n'existe pas ? On arrête ici
if (post == null) return NotFound();
// Supprimer tous les commentaires du post !
foreach(Comment c in post.Comments)
{
_context.Comment.Remove(c);
}
// Supprimer le post et enfin, sauvegarder tous les changements
_context.Post.Remove(post);
await _context.SaveChangesAsync();
return Ok(new { Message = "Suppression du post complétée !"});
}
Notez que nous aurions pu supprimer un Comment en toute quiétude sans rien supprimer d'autre.