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.66k stars 3.16k forks source link

DefaultTypeMapping().HasConversion() ignores mapping hints #32533

Open Timovzl opened 9 months ago

Timovzl commented 9 months ago

Where ModelConfigurationBuilder.Properties<TProperty>().HaveConversion<TConverter>() honors the converter including its mapping hints, ModelConfigurationBuilder.DefaultTypeMapping<TScalar>().HasConversion<TConverter>() will use the converter but not its mapping hints.

This can be an issue where the type matters outside the context of a property. For example, when custom struct is used in place of a decimal, EF writes a cast to decimal(precision, scale) into the query, such as when using Sum(). A cast to decimal is there, indicating that EF is aware of the conversion, but the precision and scale default back to 18,2 instead of what the mapping hint has configured.

Minimal Repro

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ComplexTypeConstructorBindingDemo;

internal class Program
{
    public static void Main()
    {
        var builder = Host.CreateApplicationBuilder();

        builder.Services.AddPooledDbContextFactory<TestDbContext>(dbContext => dbContext
            .UseSqlServer(@"Data Source=(LocalDB)\MSSQLLocalDB;Integrated Security=True;Initial Catalog=ComplexTypeConstructorBindingDemo;", sqlServer => sqlServer.EnableRetryOnFailure()));

        using var host = builder.Build();
        host.Start();

        using var dbContext = host.Services.GetRequiredService<IDbContextFactory<TestDbContext>>().CreateDbContext();

        dbContext.Database.EnsureCreated();

        // Extremely simple example to demonstrate the ignored MappingHints on the DefaultTypeMapping:
        // The default type mapping applies to "property-less" scenarios, such as type casts
        // EF is aware of the conversion, using "decimal" in place of "FruitId"
        // However, it ignores the mapping hints, using "decimal(18,2)" instead of the requested "decimal(28,0)"
        var query = dbContext.Set<Banana>()
            .Select(x => (FruitId)x.Id);

        var queryString = query.ToQueryString();
        Console.WriteLine(queryString);

        var result = query.ToListAsync();

        host.StopAsync().GetAwaiter().GetResult();
    }
}

#region DbContext Configuration

internal class TestDbContext(
    DbContextOptions<TestDbContext> options)
    : DbContext(options)
{
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        base.ConfigureConventions(configurationBuilder);

        // This one is just to show that it is not decimal's configured default being used either, but the regular "18, 2" default
        configurationBuilder.DefaultTypeMapping<decimal>()
            .HasPrecision(20, 5);

        configurationBuilder.Properties<BananaId>()
            .HaveConversion<IdConverter<BananaId, decimal>>();
        configurationBuilder.DefaultTypeMapping<BananaId>()
            .HasConversion<IdConverter<BananaId, decimal>>();

        configurationBuilder.Properties<FruitId>()
            .HaveConversion<IdConverter<FruitId, decimal>>();
        configurationBuilder.DefaultTypeMapping<FruitId>()
            .HasConversion<IdConverter<FruitId, decimal>>();
    }

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

        modelBuilder.Entity<Banana>(item =>
        {
            item.Property(x => x.Id);

            item.HasKey(x => x.Id);
        });
    }

    private class IdConverter<TWrapper, TValue>()
        : CastingConverter<TWrapper, TValue>(
            new ConverterMappingHints(precision: 28, scale: 0)) // Here is the mapping hint
    {
    }
}

#endregion

#region Model

internal class Banana // Entity
{
    public BananaId Id { get; }

    public Banana(BananaId id)
    {
        this.Id = id;
    }

    private Banana() // Reconstitution only
    {
    }
}

internal readonly struct BananaId(decimal value)
{
    public decimal Value { get; } = value;

    public static implicit operator decimal(BananaId instance) => instance.Value;
    public static implicit operator BananaId(decimal value) => new BananaId(value);

    public static implicit operator FruitId(BananaId instance) => new FruitId(instance.Value);
    public static implicit operator BananaId(FruitId value) => new BananaId(value);
}

internal readonly struct FruitId(decimal value)
{
    public decimal Value { get; } = value;

    public static implicit operator decimal(FruitId instance) => instance.Value;
    public static implicit operator FruitId(decimal value) => new FruitId(value);
}

#endregion

Query String

SELECT CAST([b].[Id] AS decimal(18,2))
FROM [Banana] AS [b]

Expected Query String

SELECT CAST([b].[Id] AS decimal(28,0))
FROM [Banana] AS [b]

Include provider and version information

EF Core version: Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 8.0 Operating system: Windows 10 IDE: Visual Studio 2022 17.8.0

ogix commented 4 months ago

I am not sure if it is related but I am trying to set a Converter with DefaultMapping for my SaleType value object to string type. What I expect is is that EF Core will use this conversion for collection properties i.e. List<SaleType> to save them as primitive collection.

configurationBuilder.DefaultTypeMapping<SaleType>().HasConversion<EnumValueObjectConverter<SaleType>>();

But I get this error:

The exception 'The 'List' property 'Pricing.SaleTypes' could not be mapped because the database provider does not support this type

ajcvickers commented 3 months ago

@ogix Please open a new issue and attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.