dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.5k stars 3.13k forks source link

Support `HasConversion` to primitive collection types #33688

Open PascalArdex opened 2 months ago

PascalArdex commented 2 months ago

Description

In the Entities we use Enum types a lot and we make sure to store them as strings instead of numbers For this we use .HasConversion<string>(); and it works great.

Now we would like to also support collections of Enums and have them serialized as text[] for consistency And you would think this is a job for EF Core 8 Primitive Collections and we are going to be able to simply use .HasConversion<string[]>(); but no luck.

Example

Entity

public class Rider
{
    public Guid Id { get; set; }
    public List<EquineBeast> Mounts { get; set; }
}

public enum EquineBeast
{
    Donkey,
    Mule,
    Horse,
    Unicorn
}

I will include the full code because we configure all Enum based entities at once

OnModelCreating

    private void SaveEnumAsString(ModelBuilder modelBuilder)
    {
        var entityTypes = modelBuilder.Model.GetEntityTypes();

        foreach (var entityType in entityTypes)
        {
            var enumProperties = entityType.ClrType.GetProperties()
                .Where(p => p.PropertyType.IsEnum
                    || (p.PropertyType.IsGenericType
                        && p.PropertyType.GetGenericTypeDefinition() == typeof(List<>)
                        && p.PropertyType.GetGenericArguments()[0].IsEnum));

            foreach (var enumProperty in enumProperties)
            {
                if(enumProperty.PropertyType.IsEnum)
                {
                    modelBuilder
                        .Entity(entityType.ClrType)
                        .Property(enumProperty.Name)
                        .HasConversion<string>();
                }
                else
                {
                    modelBuilder
                        .Entity(entityType.ClrType)
                        .Property(enumProperty.Name)
                        .HasConversion<string[]>();
                }
            }
        }
    }

Problem

Now here is the odd part. When we create the migration EF Core is stuck with int[]

            migrationBuilder.CreateTable(
                name: "riders",
                columns: table => new
                {
                    id = table.Column<Guid>(type: "uuid", nullable: false),
                    mount = table.Column<int[]>(type: "integer[]", nullable: false)
                },
                constraints: table =>
                {
                    table.PrimaryKey("pk_riders", x => x.id);
                });

We can force the column type with .HasColumnType("text[]") but it is creating more problems down the path. Namely the migration will apply but at runtime fails to load/convert the field

Exception has occurred: CLR/System.InvalidCastException
Exception thrown: 'System.InvalidCastException' in Microsoft.EntityFrameworkCore.Relational.dll: 'Reading as 'System.Int32[]' is not supported for fields having DataTypeName 'text[]''

Version information

EF Core version: Microsoft.EntityFrameworkCore.Design 8.0.0 Database provider: Npgsql.EntityFrameworkCore.PostgreSQL 8.0.0 Target framework: net8.0

ajcvickers commented 2 months ago

@PascalArdex You should not need all the code you have when dealing with non-collections of enums. Instead, this should work:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<EquineBeast>().HaveConversion<string>();
}

However, this is not yet supported for primitive collections--this is tracked by #31413. Instead you should be able to adapt the code you already have to configure the element type using this pattern:

modelBuilder.Entity<Rider>().PrimitiveCollection(e => e.Mounts).ElementType().HasConversion<string>();
PascalArdex commented 1 month ago

It works, thanks for your help. Thanks for pointing out that we can configure entities individually (and the syntax is much simpler). Unfortunately we take the save enums as string for granted and too often we just forget to add the line ;-)

Here is the final code if anyone stumble into this

    private void SaveEnumAsString(ModelBuilder modelBuilder)
    {
        var entityTypes = modelBuilder.Model.GetEntityTypes();

        foreach (var entityType in entityTypes)
        {
            var enumProperties = entityType.ClrType.GetProperties()
                .Where(p => p.PropertyType.IsEnum
                    || (p.PropertyType.IsGenericType
                        && p.PropertyType.GetGenericTypeDefinition() == typeof(List<>)
                        && p.PropertyType.GetGenericArguments()[0].IsEnum));

            foreach (var enumProperty in enumProperties)
            {
                if(enumProperty.PropertyType.IsEnum)
                {
                    modelBuilder
                        .Entity(entityType.ClrType)
                        .Property(enumProperty.Name)
                        .HasConversion<string>();
                }
                else
                {
                    modelBuilder
                        .Entity(entityType.ClrType)
                        .PrimitiveCollection(enumProperty.Name)
                        .ElementType()
                        .HasConversion<string>();
                }
            }
        }
    }