mapper (1.0.1)
Installation
dotnet nuget add source --name Yoann --username your_username --password your_token dotnet add package --source Yoann --version 1.0.1 mapperAbout this package
Librairie qui sert de mapper pour les objets
Quelques mots sur ce NuGet
L'utilité de ce package est arrivée lors de la mise en place d'une tarification du package AutoMapper lors de le mise en production d'un projet. Ce package permet :
- un gain de temps non négligeable
- une réduction significative du code
- un contrôle totale sur le code
- une source unique pour bénéficier de cette fonctionnalité
- une meilleure performance (moins de réflexion)
- un import beaucoup plus léger
Table des matières
Installation
Utilisation
Installation
Une documentation est disponible sur ce sujet ici 👉 docmost
Mais assurez vous d'avoir cette source https://git.lightyear-corp.fr/api/packages/Yoann/nuget/index.json dans votre fichier qui regroupe les sources d'approvisionnement des packages.
Après avoir lu la documentation, vous n'avez plus qu'à ajouter le package si ça n'est pas déjà fait en executant cette commande.
dotnet add package mapper
Prérequis
.NET 9.0 ou supérieur
Utilisation
1. Définir vos mappings
Créez une classe de configuration (similaire à un Profile AutoMapper) :
using mapper;
public static class UserMapperProfile
{
/// <summary>
/// clsse qui illustre tous les mappings relatifs aux utilisateurs.
/// cette méthode est appelée au démarrage de l'application.
/// </summary>
public static void Configure(MappingConfiguration cfg)
{
// mapping simple DTO -> Entity
// copie automatiquement toutes les propriétés avec le même nom
cfg.CreateMap<CreateUserDto, User>();
cfg.CreateMap<UpdateUserDto, User>();
// Mapping Entity -> DTO avec transformations
cfg.CreateMap<User, UserDto>()
// Concatène FirstName et LastName pour créer FullName
.ForMember(d => d.FullName, src => $"{src.FirstName} {src.LastName}")
// Calcule IsActive en fonction de DeletedAt
.ForMember(d => d.IsActive, src => src.DeletedAt == null)
// Ignore les propriétés système
.Ignore(d => d.Id);
// Mapping pour résumé avec propriétés ignorées
cfg.CreateMap<User, UserSummaryDto>()
.Ignore(d => d.CreatedAt)
.Ignore(d => d.UpdatedAt);
// Mapping pour PATCH : ignore les valeurs NULL dans le DTO source
// Permet les mises à jour partielles sans écraser les champs non fournis
cfg.CreateMap<PatchUserDto, User>()
.SkipNullSourceValues();
}
}
2. Enregistrer dans l'injection de dépendances
Dans votre fichier Program.cs ou dans votre classe qui sert de configuration :
// Configuration de mapper
builder.Services.AddSingleton<IObjectMapper>(serviceProvider =>
{
var cfg = new MappingConfiguration();
// Chargez tous vos profils de mapping
UserMapperProfile.Configure(cfg);
RoleMapperProfile.Configure(cfg);
OrderMapperProfile.Configure(cfg);
// retourne le mapper configuré (singleton)
return new ObjectMapper(cfg);
});
Pourquoi Singleton ? Le mapper ne contient que de la configuration statique, il n'a pas d'état changeant. Un singleton économise la mémoire et améliore les performances.
3. Utiliser dans vos services
public class UserService
{
private readonly IObjectMapper _mapper;
private readonly IUserRepository _userRepo;
public UserService(IObjectMapper mapper, IUserRepository userRepo)
{
_mapper = mapper;
_userRepo = userRepo;
}
/// <summary>
/// CREATE : Convertit un DTO en entité, la sauvegarde, puis retourne un DTO
/// </summary>
public async Task<UserDto> CreateAsync(CreateUserDto dto)
{
// DTO -> Entity
var user = _mapper.Map<User>(dto);
// ajoute à la base de données
await _userRepo.AddAsync(user);
// Entity -> DTO (pour la réponse API)
return _mapper.Map<UserDto>(user);
}
/// <summary>
/// READ : Récupère une entité et la convertit en DTO
/// </summary>
public async Task<UserDto> GetByIdAsync(int id)
{
var user = await _userRepo.GetByIdAsync(id);
if (user == null)
throw new NotFoundException($"User {id} non trouvé");
return _mapper.Map<UserDto>(user);
}
/// <summary>
/// UPDATE complet : Remplace toutes les propriétés
/// </summary>
public async Task UpdateAsync(UpdateUserDto dto)
{
var user = await _userRepo.GetByIdAsync(dto.Id);
// Map le DTO sur l'instance existante (UPDATE)
// Cela modifie directement 'user' avec les valeurs du DTO
_mapper.Map(dto, user);
await _userRepo.UpdateAsync(user);
}
/// <summary>
/// PATCH partiel : Met à jour seulement les propriétés non-null
/// </summary>
public async Task PatchAsync(int id, PatchUserDto patch)
{
var user = await _userRepo.GetByIdAsync(id);
// SkipNullSourceValues fait que seules les props non-null du patch sont copiées
// si patch.Email est null, le email de 'user' n'est pas modifié
_mapper.Map(patch, user);
await _userRepo.UpdateAsync(user);
}
/// <summary>
/// lisdt : Récupère plusieurs entités et les convertit en DTOs
/// </summary>
public async Task<List<UserDto>> GetAllAsync()
{
var users = await _userRepo.GetAllAsync();
// Depuis 1.0.1, mapper gère les collections : _mapper.Map<List<UserDto>>(users) fonctionne.
// La forme LINQ ci-dessous reste équivalente si vous préférez l'expliciter.
return users.Select(u => _mapper.Map<UserDto>(u)).ToList();
}
/// <summary>
/// Exemple avec filtre avancé
/// </summary>
public async Task<List<UserDto>> GetActiveUsersAsync()
{
var users = await _userRepo.GetAllAsync();
// filtre puis mappe
return users
.Where(u => u.DeletedAt == null)
.Select(u => _mapper.Map<UserDto>(u))
.ToList();
}
}
Concepts fondamentaux
Mapping unidirectionnel vs bidirectionnel
// Unidirectionnel (une seule direction)
cfg.CreateMap<CreateUserDto, User>(); // DTO -> Entity seulement
// Bidirectionnel (deux directions)
cfg.CreateMap<User, UserDto>(); // Entity -> DTO
cfg.CreateMap<UserDto, User>(); // DTO -> Entity
// les deux directions ne sont PAS automatiques
// vous devez explicitement créer les deux mappings
CreateMap : Copie automatique des propriétés
// createMao copie AUTOMATIQUEMENT toutes les propriétés ayant :
// - le même nom (case-insensitive)
// - un type compatible
public class User
{
public int Id { get; set; }
public string FirstName { get; set; }
public string Email { get; set; }
}
public class UserDto
{
public int Id { get; set; }
public string FirstName { get; set; }
public string Email { get; set; }
}
cfg.CreateMap<User, UserDto>(); // aucune config nécessaire!!!
ForMember : Personnaliser le mapping d'une propriété
cfg.CreateMap<User, UserDto>()
// prop complexe : créer à partir de plusieurs champs
.ForMember(d => d.FullName, src => $"{src.FirstName} {src.LastName}")
// prop calculée : transformer les données
.ForMember(d => d.IsActive, src => src.DeletedAt == null)
// enum : mapper à partir d'une string
.ForMember(d => d.RoleLabel, src =>
src.Role switch
{
UserRole.Admin => "Administrateur",
UserRole.User => "Utilisateur",
_ => "Inconnu"
})
// accès à des propriétés imbriquées
.ForMember(d => d.ManagerName, src =>
src.Manager?.FirstName + " " + src.Manager?.LastName)
// convertion de type
.ForMember(d => d.BirthDateString, src => src.BirthDate.ToString("dd/MM/yyyy"));
Ignore : Ignorer certaines propriétés
cfg.CreateMap<User, UserDto>()
// prop sensibles (sécurité)
.Ignore(d => d.PasswordHash)
.Ignore(d => d.PasswordSalt)
.Ignore(d => d.InternalNotes)
// prop trop volumineuses (perf)
.Ignore(d => d.LargeDocumentContent);
SkipNullSourceValues : PATCH intelligent
cfg.CreateMap<PatchUserDto, User>()
.SkipNullSourceValues(); // ignore les propriétés null du DTO
// avant l'utilisation de SkipNullSourceValues :
var patch = new PatchUserDto { Email = "new@example.com", FirstName = null };
_mapper.Map(patch, user);
// user.FirstName = null (écrasé!)
// Après SkipNullSourceValues :
var patch = new PatchUserDto { Email = "new@example.com", FirstName = null };
_mapper.Map(patch, user);
// user.Email = "new@example.com" (changé)
// user.FirstName reste inchangé (null ignoré)
Classe présente dans le package NuGet
Interface MappingConfiguration
public class MappingConfiguration
{
/// <summary>
/// Crée un nouveau mapping de Source vers Destination
/// </summary>
public MappingExpression<TSource, TDestination> CreateMap<TSource, TDestination>()
where TSource : class
where TDestination : class, new();
}
Interface MappingExpression
public class MappingExpression<TSource, TDestination>
{
/// <summary>
/// Personnalise le mapping d'une propriété destination
/// </summary>
/// <param name="destinationSelector">La propriété à mapper</param>
/// <param name="sourceMember">La transformation à appliquer</param>
public MappingExpression<TSource, TDestination> ForMember<TMember>(
Expression<Func<TDestination, TMember>> destinationSelector,
Func<TSource, TMember> sourceMember);
/// <summary>
/// Ignore complètement une propriété (ne la mappe jamais)
/// </summary>
public MappingExpression<TSource, TDestination> Ignore<TMember>(
Expression<Func<TDestination, TMember>> destinationSelector);
/// <summary>
/// Ignore les propriétés null dans la source lors du mapping
/// Utile pour les PATCH
/// </summary>
public MappingExpression<TSource, TDestination> SkipNullSourceValues();
}
Exemples d'utilisation complète
- Mapping simple (copie automatique)
cfg.CreateMap<User, UserDto>();
// ça ;équivayut à :
var user = new User { Id = 1, FirstName = "Yoann", Email = "john.doe@example.com" };
var dto = _mapper.Map<UserDto>(user);
// dto.Id = 1
// dto.FirstName = "Yoann"
// dto.Email = "john.doe@example.com"
- Mapping avec transformations
cfg.CreateMap<Order, OrderSummaryDto>()
// Concat strings
.ForMember(d => d.CustomerInfo,
src => $"{src.Customer.FirstName} {src.Customer.LastName}")
// Calcul mathématique
.ForMember(d => d.TotalPrice,
src => src.Items.Sum(i => i.Price * i.Quantity))
// Condition
.ForMember(d => d.StatusLabel,
src => src.IsPaid ? "Payée" : "En attente")
// Switch expression
.ForMember(d => d.Priority,
src => src.Amount switch
{
> 1000 => "Haute",
> 500 => "Moyenne",
_ => "Basse"
});
- Mapping avec null-safety (si une prop est nulle l'applciation va crash)
cfg.CreateMap<Employee, EmployeeDto>()
.ForMember(d => d.DepartmentName,
src => src.Department?.Name ?? "Non assigné")
// null-conditionnel + default
.ForMember(d => d.ManagerFullName,
src => src.Manager != null
? $"{src.Manager.FirstName} {src.Manager.LastName}"
: "Pas de manager")
// collecction null-safe
.ForMember(d => d.ProjectCount,
src => (src.Projects ?? new List<Project>()).Count);
- Mapping pour PATCH (mise à jour partielle)
cfg.CreateMap<PatchUserDto, User>()
.SkipNullSourceValues()
.ForMember(d => d.Email, src => src.Email?.ToLower()) // normalisation optionnelle
.Ignore(d => d.Id) // jamais modifier l'ID
.Ignore(d => d.CreatedAt)
.Ignore(d => d.DeletedAt);
// utilisation de notre package :
var patch = new PatchUserDto
{
FirstName = "Nouveau",
Email = null // null du coup ça n'est pas copié
};
_mapper.Map(patch, user);
// user.FirstName = "Nouveau"
// user.Email reste inchangé
Les collections
Depuis la version 1.0.1, mapper prend en charge le mapping direct de collections. En interne, il mappe chaque élément un par un (même logique unitaire), puis regroupe le résultat dans le type de destination.
var users = new List<User> { ... };
var dtos = _mapper.Map<List<UserDto>>(users); // List<UserDto>
var dtos2 = _mapper.Map<IEnumerable<UserDto>>(users); // IEnumerable<UserDto>
var dtos3 = _mapper.Map<UserDto[]>(users); // UserDto[]
Types de destination pris en charge : List<T>, IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T> et les tableaux T[].
La forme explicite
users.Select(_mapper.Map<UserDto>).ToList()reste évidemment valide et strictement équivalente ; utilisez celle que vous préférez.
Intégration dans une classe Repository générique
BaseRepository (avec projection_
public class BaseRepository<TEntity> : IBaseRepository<TEntity>
where TEntity : class
{
private readonly IObjectMapper _mapper;
/// <summary>
/// Récupère des entités avec projection vers un DTO
/// </summary>
public async Task<List<TDto>> GetAllWithProjectionAsync<TDto>(
CancellationToken cancellationToken = default)
where TDto : class
{
var entities = await _dbSet.ToListAsync(cancellationToken);
// optimisation : pas de mapping si même type
if (typeof(TDto) == typeof(TEntity))
return entities.Cast<TDto>().ToList();
// mapping élément par élément
return entities
.Select(e => _mapper.MapWithoutCtor<TEntity, TDto>(e))
.ToList();
}
/// <summary>
/// Récupère des entités avec filtre et projection vers un DTO
/// </summary>
public async Task<List<TDto>> GetWithCustomFilterAsync<TDto>(
Expression<Func<TEntity, bool>>? filter = null,
CancellationToken cancellationToken = default)
where TDto : class
{
var query = _dbSet.AsNoTracking();
if (filter != null)
query = query.Where(filter);
var entities = await query.ToListAsync(cancellationToken);
//
if (typeof(TDto) == typeof(TEntity))
return entities.Cast<TDto>().ToList();
// mapping
return entities
.Select(e => _mapper.MapWithoutCtor<TEntity, TDto>(e))
.ToList();
}
/// <summary>
/// Récupère une seule entité avec projection vers un DTO
/// </summary>
public async Task<TDto?> GetSingleWithFilterAsync<TDto>(
Expression<Func<TEntity, bool>> filter,
CancellationToken cancellationToken = default)
where TDto : class
{
var entity = await _dbSet
.FirstOrDefaultAsync(filter, cancellationToken);
if (entity == null)
return null;
// optimisation
if (typeof(TDto) == typeof(TEntity))
return entity as TDto;
// mapping
return _mapper.MapWithoutCtor<TEntity, TDto>(entity);
}
/// <summary>
/// Récupère des entités paginées avec projection vers un DTO
/// </summary>
public async Task<(List<TDto> Data, int Total, int Pages)> GetPaginatedAsync<TDto>(
int pageNumber = 1,
int pageSize = 10,
Expression<Func<TEntity, bool>>? filter = null)
where TDto : class
{
var query = _dbSet.AsNoTracking();
if (filter != null)
query = query.Where(filter);
var total = await query.CountAsync();
var pages = (int)Math.Ceiling(total / (double)pageSize);
var entities = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// optimisation
if (typeof(TDto) == typeof(TEntity))
return (entities.Cast<TDto>().ToList(), total, pages);
// mapping
var dtos = entities
.Select(e => _mapper.MapWithoutCtor<TEntity, TDto>(e))
.ToList();
return (dtos, total, pages);
}
}
Mise à jour partielle (PATCH)
// DTOs
public class PatchUserDto
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public DateTime? BirthDate { get; set; }
}
// Configuration (avec normalisation)
cfg.CreateMap<PatchUserDto, User>()
.SkipNullSourceValues()
.ForMember(d => d.Email, src => src.Email?.ToLower().Trim())
.ForMember(d => d.FirstName, src => src.FirstName?.Trim())
.ForMember(d => d.LastName, src => src.LastName?.Trim())
.Ignore(d => d.Id)
.Ignore(d => d.CreatedAt)
.Ignore(d => d.UpdatedAt);
// Service
public async Task<UserDto> PatchUserAsync(int id, PatchUserDto patch)
{
var user = await _userRepository.GetByIdAsync(id);
// map du patch (seules les props non-null sont modifiées)
_mapper.Map(patch, user);
// sauvegarde bdd
await _userRepository.UpdateAsync(user);
return _mapper.Map<UserDto>(user);
}
// Exemple d'utilisation :
var patch = new PatchUserDto
{
FirstName = "Yoann", // sera copié
Email = "john.doe@example.com", // sera copié
LastName = null // null est ignoré, LastName de l'utilisateur reste inchangé
};
await PatchUserAsync(userId, patch);