Loupe

Records C# 9 comme ids fortement typés - Partie 4 : intégration avec Entity Framework Core

Dans les précédents articles de cette série, j'ai montré qu'il était très facile d'utiliser les records C# 9 comme ids fortement typés :

public record ProductId(int Value) : StronglyTypedId<int>(Value);

J'ai également expliqué comment faire fonctionner correctement ces ids fortement typés avec le model binding d'ASP.NET Core, et comment faire pour qu'ils soient correctement sérialisés en JSON.

Aujourd'hui, on s'attaque à une autre pièce du puzzle : comment faire pour qu'Entity Framework Core gère correctement nos ids fortement typés.

Conversion de valeur pour une propriété spécifique

Par défaut, EF Core ne sait rien de nos ids fortement typés. Il les voit juste comme des types custom pour lesquels il n'existe pas de conversion connue vers un type de la base de données, donc il suppose que ce sont des entités. Du coup, si on ne fait rien de spécial, EF Core tentera de mapper le type ProductId vers une table ProductId avec une colonne Value… Ce n'est évidemment pas ce qu'on veut !

Un id fortement typé est juste un wrapper pour une valeur scalaire, il doit donc être mappé vers une colonne dans la même table que l'entité qui le déclare. Le type de cette colonne doit être compatible avec le type de la valeur de l'id, i.e. int dans le cas de ProductId.

Pour indiquer cela à EF Core, il faut spécifier une conversion à appliquer aux propriétés dont le type est un id fortement typé. Cela peut se faire en spécifiant 2 expressions pour convertir depuis ou vers l'id fortement typé. Par exemple, pour la propriété Product.Id :

modelBuilder.Entity<Product>(builder =>
{
    ...
    builder.Property(p => p.Id)
        .HasConversion(id => id.Value, value => new ProductId(value));
    ...
});

On peut aussi wrapper ces deux expressions dans un objet ValueConverter<ProductId, int>, et réutiliser cet objet pour plusieurs propriétés.

Remarquez qu'il faut appliquer la conversion à chaque propriété qui est un id fortement typé (que ce soit une clé primaire ou une clé étrangère). Il n'existe pas actuellement de mécanisme natif permettant d'appliquer la conversion à toutes les propriétés d'un type donné, mais cette fonctionnalité est envisagée pour EF Core 6.0.

Appliquer la conversion à tous les ids fortement typés

Appliquer manuellement la conversion à tous les ids fortement typés de notre modèle va vite devenir ennuyeux, vous ne trouvez pas ? Voyons donc comment arranger ça !

On va examiner chaque propriété de chaque entité dans le modèle EF Core, et s'il s'agit d'un id fortement typé, on va utiliser la réflexion pour générer le convertisseur approprié et l'appliquer à la propriété. C'est ce que fait la méthode suivante, qu'il faut appeler depuis OnModelCreating :

private static void AddStronglyTypedIdConversions(ModelBuilder modelBuilder)
{
    foreach (var entityType in modelBuilder.Model.GetEntityTypes())
    {
        foreach (var property in entityType.GetProperties())
        {
            if (StronglyTypedIdHelper.IsStronglyTypedId(property.ClrType, out var valueType))
            {
                var converter = StronglyTypedIdConverters.GetOrAdd(
                    property.ClrType,
                    _ => CreateStronglyTypedIdConverter(property.ClrType, valueType));
                property.SetValueConverter(converter);
            }
        }
    }
}

private static readonly ConcurrentDictionary<Type, ValueConverter> StronglyTypedIdConverters = new();

private static ValueConverter CreateStronglyTypedIdConverter(
    Type stronglyTypedIdType,
    Type valueType)
{
    // id => id.Value
    var toProviderFuncType = typeof(Func<,>)
        .MakeGenericType(stronglyTypedIdType, valueType);
    var stronglyTypedIdParam = Expression.Parameter(stronglyTypedIdType, "id");
    var toProviderExpression = Expression.Lambda(
        toProviderFuncType,
        Expression.Property(stronglyTypedIdParam, "Value"),
        stronglyTypedIdParam);

    // value => new ProductId(value)
    var fromProviderFuncType = typeof(Func<,>)
        .MakeGenericType(valueType, stronglyTypedIdType);
    var valueParam = Expression.Parameter(valueType, "value");
    var ctor = stronglyTypedIdType.GetConstructor(new[] { valueType });
    var fromProviderExpression = Expression.Lambda(
        fromProviderFuncType,
        Expression.New(ctor, valueParam),
        valueParam);

    var converterType = typeof(ValueConverter<,>)
        .MakeGenericType(stronglyTypedIdType, valueType);

    return (ValueConverter)Activator.CreateInstance(
        converterType,
        toProviderExpression,
        fromProviderExpression,
        null);
}

La partie compliquée ici est la méthode CreateStronglyTypedIdConverter. Elle génère dynamiquement les expressions id => id.Value et value => new ProductId(value), en utilisant la réflexion et l'API des Expressions Linq. On utilise un cache pour éviter de refaire le travail plusieurs fois.

Si on configure explicitement les clés primaires et les relations de nos entités, ça marche sans problème. Par contre, si on se repose juste sur les conventions d'EF Core, on a un souci : entityType.GetProperties() ne renvoie pas la propriété Id de Product ! Prenons un peu de recul pour comprendre pourquoi.

Avant d'appeler OnModelCreating pour permettre à l'utilisateur de personaliser le modèle, EF Core crée le modèle sur la base de conventions. L'une de ces conventions est qu'une propriété nommée Id est considérée par défaut comme la clé primaire de l'entité. Cependant, comme ProductId est un type "complexe", EF Core suppose que c'est une entité, plutôt qu'une valeur scalaire. Une entité ne peut pas être une clé, et la propriété Id est donc considérée comme une propriété de navigation vers l'entité ProductId. Cela a des effets secondaires ennuyeux : EF Core ajoute implicitement une propriété IdTempId comme clé primaire pour la relation avec ProductId, et la propriété Id elle-même n’apparaît pas comme une propriété scalaire (puisque c'est une propriété de navigation).

La situation semble un peu inextricable, mais heureusement on peut s'en sortir sans trop de mal. Il suffit de configurer explicitement la clé de chaque entité, comme ceci :

modelBuilder.Entity<Product>(builder => builder.HasKey(p => p.Id));

(remarquez que ça doit être fait avant l'appel à AddStronglyTypedIdConversions)

Il faudra également configurer explicitement les relations entre les entités pour que les clés étrangères soient correctement gérées. Personnellement ces configurations manuelles ne me dérangent pas vraiment, car je préfère définir mon modèle explicitement plutôt que de m'appuyer sur les conventions, mais il est vrai que cela demande un peu plus de travail… Il y a probablement un moyen d'appliquer cette configuration de façon générique, mais je n'ai pas encore trouvé une approche vraiment satisfaisante. Ce problème va sans doute disparaître quand #10784 sera réglé.

Au final, notre méthode OnModelCreating ressemble à ceci :

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // Configure each entity type
    modelBuilder.Entity<Product>(builder => builder.HasKey(p => p.Id));

    AddStronglyTypedIdConversions(modelBuilder);
}

A ce stade, tout devrait fonctionner normalement. Les records implémentent automatiquement l'opérateur ==, un code comme celui-ci aura donc le comportement attendu :

public Product GetProductById(ProductId id)
{
    return _dbContext.Products.SingleOrDefault(p => p.Id == id);
}

Notez que cela fonctionne avec EF Core 5.0, mais pas nécessairement avec des versions plus anciennes, où cela peut causer des problèmes d'évaluation côté client. Je ne sais pas dans quelle version exactement ça a été corrigé, mais en tout cas ça marche bien en 5.0.

Conclusion

Dans cet article, nous avons vu comment faire fonctionner nos ids fortement typés avec Entity Framework Core.

La plupart des problématiques liées à l'utilisation des ids fortement typés sont désormais résolues, on s'approche de la fin de cette série. Il reste juste quelques petits soucis plus mineurs à régler, que j'aborderai dans le prochain article.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus