- 
                Notifications
    You must be signed in to change notification settings 
- Fork 316
Description
This is the issue we discussed at the last EF/SqlClient sync. It is related to dotnet/efcore#19293
Consider attempting to insert the value 10.9876 into a decimal(18, 2) column.
- If DbParameter.Precisionis set to 18, andDbParameter.Scaleis set to 2, then- SqlClient truncates the value to 10.98before inserting.
 
- SqlClient truncates the value to 
- If DbParameter.PrecisionandDbParameter.Scaleare not set, then:- SqlClient passes the full 10.9876to SQL Server
- SQL Server rounds this to 10.99.
 
- SqlClient passes the full 
Historically the behavior in SqlClient could not be changed, since it would be a break for existing applications that are relying on truncation.
At the same time, EF didn't set precision and scale on decimal parameters (since before my time on the team!) which meant EF was relying on SQL Server rounding.
Initially in EF Core we set precision and scale, but people argued that truncation was a deviation from EF6 behavior and that rounding was better. In fact, a reasonable case could be made that the truncation behavior was wrong. We concurred and so we kept the rounding behavior in EF Core.
Enter always-encrypted. Now not setting precision and scale on a decimal parameter causes SQL Server to throw. See dotnet/efcore#19293.
But if EF starts setting precision and scale, then the behavior will change to truncation.
So, options here are:
- SqlClient introduces a rounding behavior. Note that this is breaking if it is on by default.
- EF introduces its own rounding behavior before passing the value to SqlClient
- We start throwing and require applications truncate/round as appropriate. This is also breaking.
After writing this, I'm leaning even more towards EF doing rounding. But we should first be sure that SqlClient doesn't want to change or introduce new behaviors.
Repro code. The code uses EF only to create a database and print results. The inserts use SqlClient directly.
public static class ThreeOne
{
    public static void Main()
    {
        // Use use EF to create a test database
        using (var context = new SomeDbContext())
        {
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
        }
        InsertRow(1, 10.9876m, null);
        InsertRow(2, 10.9876m, (18, 2));
        // Use use EF to easily read the inserted values back
        using (var context = new SomeDbContext())
        {
            var products = context.Products.ToList();
            Console.WriteLine($"Product {products[0].Id} has decimal value {products[0].Price}");
            Console.WriteLine($"Product {products[1].Id} has decimal value {products[1].Price}");
        }
        void InsertRow(int id, decimal price, (byte, byte)? precisionAndScale)
        {
            using var connection = new SqlConnection(Your.SqlServerConnectionString);
            using var command = connection.CreateCommand();
            var idParameter = command.CreateParameter();
            idParameter.ParameterName = "p0";
            idParameter.Value = id;
            var priceParameter = command.CreateParameter();
            priceParameter.ParameterName = "p1";
            priceParameter.DbType = DbType.Decimal;
            if (precisionAndScale.HasValue)
            {
                var (precision, scale) = precisionAndScale.Value;
                priceParameter.Precision = precision;
                priceParameter.Scale = scale;
            }
            priceParameter.Value = price;
            command.Parameters.Add(idParameter);
            command.Parameters.Add(priceParameter);
            command.CommandText = "INSERT INTO [Products] ([Id], [Price]) VALUES (@p0, @p1)";
            connection.Open();
            command.ExecuteNonQuery();
            connection.Close();
        }
    }
}
public class SomeDbContext : DbContext
{
    private static readonly ILoggerFactory
        Logger = LoggerFactory.Create(x => x.AddConsole()); //.SetMinimumLevel(LogLevel.Debug));
    public DbSet<Product> Products { get; set; }
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            //.UseLoggerFactory(Logger)
            .EnableSensitiveDataLogging()
            .UseSqlServer(Your.SqlServerConnectionString);
}
public class Product
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int Id { get; set; }
    
    [Column(TypeName = "decimal(18,2)")]
    public decimal Price { get; set; }
}