dotnet / ef6

This is the codebase for Entity Framework 6 (previously maintained at https://entityframework.codeplex.com). Entity Framework Core is maintained at https://github.com/dotnet/efcore.
https://docs.microsoft.com/ef/ef6
MIT License
1.42k stars 545 forks source link

Fix Entity Framework so it does not "assume" the SQL datatype is DateTime2 when using SQL 2008 and greater #578

Closed ImGonaRot closed 2 years ago

ImGonaRot commented 6 years ago

The EF code assumes that all .NET DateTime properties should be cast as DateTime2 SQL data types when the SQL version is 2008 and greater.

Please adjust EF so that it "assumes" DateTime (this will not break current code since DateTime and DateTime2 are interchangeable up until SQL 2016 https://docs.microsoft.com/en-us/sql/database-engine/breaking-changes-to-database-engine-features-in-sql-server-2016?view=sql-server-2017) and then let the caller add an attribute to the .NET DateTime property to tell EF wither to use DateTime or DateTime2 data type.

Something like...

[Column(TypeName ="datetime2")]
public DateTime TestDate { get; set; }

[Column(TypeName ="datetime")] // Not needed by default
public DateTime AnotherTestDate { get; set; }

Here are the places in the EF code that needs "fixed": All code is in the "EntityFramework.SqlServr" .NET project.

  1. class SqlFunctionCallHandler
    
    // <summary>
    // See <see cref="HandleCanonicalFunctionDateTimeTypeCreation" /> for exact translation
    // Pre Katmai creates datetime.
    // On Katmai creates datetime2.
    // </summary>
    private static ISqlFragment HandleCanonicalFunctionCreateDateTime(SqlGenerator sqlgen, DbFunctionExpression e)
    {            
    var typeName = (sqlgen.IsPreKatmai) ? "datetime" : "datetime2";
    return HandleCanonicalFunctionDateTimeTypeCreation(sqlgen, typeName, e.Arguments, true, false);
    }

//

// TruncateTime(DateTime X) // PreKatmai: TRUNCATETIME(X) => CONVERT(DATETIME, CONVERT(VARCHAR(255), expression, 102), 102) // Katmai: TRUNCATETIME(X) => CONVERT(DATETIME2, CONVERT(VARCHAR(255), expression, 102), 102) // TruncateTime(DateTimeOffset X) // TRUNCATETIME(X) => CONVERT(datetimeoffset, CONVERT(VARCHAR(255), expression, 102) // + ' 00:00:00 ' + Right(convert(varchar(255), @arg, 121), 6), 102) // private static ISqlFragment HandleCanonicalFunctionTruncateTime(SqlGenerator sqlgen, DbFunctionExpression e) { //The type that we need to return is based on the argument type. string typeName = null; var isDateTimeOffset = false;

var typeKind = e.Arguments[0].ResultType.GetPrimitiveTypeKind();

if (typeKind == PrimitiveTypeKind.DateTime)
{
    typeName = sqlgen.IsPreKatmai ? "datetime" : "datetime2";
}
else if (typeKind == PrimitiveTypeKind.DateTimeOffset)
{
    typeName = "datetimeoffset";
    isDateTimeOffset = true;
}
else
{
    Debug.Assert(true, "Unexpected type to TruncateTime" + typeKind.ToString());
}

var result = new SqlBuilder();
result.Append("convert (");
result.Append(typeName);
result.Append(", convert(varchar(255), ");
result.Append(e.Arguments[0].Accept(sqlgen));
result.Append(", 102) ");

if (isDateTimeOffset)
{
    result.Append("+ ' 00:00:00 ' +  Right(convert(varchar(255), ");
    result.Append(e.Arguments[0].Accept(sqlgen));
    result.Append(", 121), 6)  ");
}

result.Append(",  102)");
return result;

}


2. class SqlGenerator

//

// Generate tsql for a constant. Avoid the explicit cast (if possible) when // the isCastOptional parameter is set // // the constant expression // can we avoid the CAST // the tsql fragment private ISqlFragment VisitConstant(DbConstantExpression e, bool isCastOptional) { // Constants will be sent to the store as part of the generated TSQL, not as parameters var result = new SqlBuilder();

var resultType = e.ResultType;
// Model Types can be (at the time of this implementation):
//      Binary, Boolean, Byte, Date, DateTime, DateTimeOffset, Decimal, Double, Guid, Int16, Int32, Int64, Single, String, Time
if (resultType.IsPrimitiveType())
{
    var typeKind = resultType.GetPrimitiveTypeKind();
    switch (typeKind)
    {
        case PrimitiveTypeKind.Int32:
            // default sql server type for integral values.
            result.Append(e.Value.ToString());
            break;

        case PrimitiveTypeKind.Binary:
            result.Append(" 0x");
            result.Append(ByteArrayToBinaryString((Byte[])e.Value));
            result.Append(" ");
            break;

        case PrimitiveTypeKind.Boolean:
            // Bugs 450277, 430294: Need to preserve the boolean type-ness of
            // this value for round-trippability
            WrapWithCastIfNeeded(!isCastOptional, (bool)e.Value ? "1" : "0", "bit", result);
            break;

        case PrimitiveTypeKind.Byte:
            WrapWithCastIfNeeded(!isCastOptional, e.Value.ToString(), "tinyint", result);
            break;

        case PrimitiveTypeKind.DateTime:
            result.Append("convert(");
            result.Append(IsPreKatmai ? "datetime" : "datetime2");
            result.Append(", ");
            result.Append(
                EscapeSingleQuote(
                    ((DateTime)e.Value).ToString(
                        IsPreKatmai ? "yyyy-MM-dd HH:mm:ss.fff" : "yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture),
                    false /* IsUnicode */));
            result.Append(", 121)");
            break;

        case PrimitiveTypeKind.Time:
            AssertKatmaiOrNewer(typeKind);
            result.Append("convert(");
            result.Append(e.ResultType.EdmType.Name);
            result.Append(", ");
            result.Append(EscapeSingleQuote(e.Value.ToString(), false /* IsUnicode */));
            result.Append(", 121)");
            break;

        case PrimitiveTypeKind.DateTimeOffset:
            AssertKatmaiOrNewer(typeKind);
            result.Append("convert(");
            result.Append(e.ResultType.EdmType.Name);
            result.Append(", ");
            result.Append(
                EscapeSingleQuote(
                    ((DateTimeOffset)e.Value).ToString("yyyy-MM-dd HH:mm:ss.fffffff zzz", CultureInfo.InvariantCulture), false
                /* IsUnicode */));
            result.Append(", 121)");
            break;

        case PrimitiveTypeKind.Decimal:
            var strDecimal = ((Decimal)e.Value).ToString(CultureInfo.InvariantCulture);
            // if the decimal value has no decimal part, cast as decimal to preserve type
            // if the number has precision > int64 max precision, it will be handled as decimal by sql server
            // and does not need cast. if precision is lest then 20, then cast using Max(literal precision, sql default precision)
            var needsCast = -1 == strDecimal.IndexOf('.') && (strDecimal.TrimStart(new[] { '-' }).Length < 20);

            var precision = Math.Max((Byte)strDecimal.Length, DefaultDecimalPrecision);
            Debug.Assert(precision > 0, "Precision must be greater than zero");

            var decimalType = "decimal(" + precision.ToString(CultureInfo.InvariantCulture) + ")";

            WrapWithCastIfNeeded(needsCast, strDecimal, decimalType, result);
            break;

        case PrimitiveTypeKind.Double:
            {
                var doubleValue = (Double)e.Value;
                AssertValidDouble(doubleValue);
                WrapWithCastIfNeeded(true, doubleValue.ToString("R", CultureInfo.InvariantCulture), "float(53)", result);
            }
            break;

        case PrimitiveTypeKind.Geography:
            AppendSpatialConstant(result, ((DbGeography)e.Value).AsSpatialValue());
            break;

        case PrimitiveTypeKind.Geometry:
            AppendSpatialConstant(result, ((DbGeometry)e.Value).AsSpatialValue());
            break;

        case PrimitiveTypeKind.Guid:
            WrapWithCastIfNeeded(true, EscapeSingleQuote(e.Value.ToString(), false /* IsUnicode */), "uniqueidentifier", result);
            break;

        case PrimitiveTypeKind.Int16:
            WrapWithCastIfNeeded(!isCastOptional, e.Value.ToString(), "smallint", result);
            break;

        case PrimitiveTypeKind.Int64:
            WrapWithCastIfNeeded(!isCastOptional, e.Value.ToString(), "bigint", result);
            break;

        case PrimitiveTypeKind.Single:
            {
                var singleValue = (float)e.Value;
                AssertValidSingle(singleValue);
                WrapWithCastIfNeeded(true, singleValue.ToString("R", CultureInfo.InvariantCulture), "real", result);
            }
            break;

        case PrimitiveTypeKind.String:
            bool isUnicode;

            if (!e.ResultType.TryGetIsUnicode(out isUnicode))
            {
                // If the unicode facet is not specified, if needed force non-unicode, otherwise default to unicode.
                isUnicode = !_forceNonUnicode;
            }
            result.Append(EscapeSingleQuote(e.Value as string, isUnicode));
            break;

        default:
            // all known scalar types should been handled already.
            throw new NotSupportedException(
                Strings.NoStoreTypeForEdmType(resultType.EdmType.Name, ((PrimitiveType)(resultType.EdmType)).PrimitiveTypeKind));
    }
}
else
{
    throw new NotSupportedException();
    //if/when Enum types are supported, then handle appropriately, for now is not a valid type for constants.
    //result.Append(e.Value.ToString());
}

return result;

}

internal static string GenerateSqlForStoreType(SqlVersion sqlVersion, TypeUsage storeTypeUsage) { Debug.Assert(BuiltInTypeKind.PrimitiveType == storeTypeUsage.EdmType.BuiltInTypeKind, "Type must be primitive type");

var typeName = storeTypeUsage.EdmType.Name;
var hasFacet = false;
var maxLength = 0;
byte decimalPrecision = 0;
byte decimalScale = 0;

var primitiveTypeKind = ((PrimitiveType)storeTypeUsage.EdmType).PrimitiveTypeKind;

switch (primitiveTypeKind)
{
    case PrimitiveTypeKind.Binary:
        if (!storeTypeUsage.MustFacetBeConstant(DbProviderManifest.MaxLengthFacetName))
        {
            hasFacet = storeTypeUsage.TryGetMaxLength(out maxLength);
            Debug.Assert(hasFacet, "Binary type did not have MaxLength facet");
            typeName = typeName + "(" + maxLength.ToString(CultureInfo.InvariantCulture) + ")";
        }
        break;

    case PrimitiveTypeKind.String:
        if (!storeTypeUsage.MustFacetBeConstant(DbProviderManifest.MaxLengthFacetName))
        {
            hasFacet = storeTypeUsage.TryGetMaxLength(out maxLength);
            Debug.Assert(hasFacet, "String type did not have MaxLength facet");
            typeName = typeName + "(" + maxLength.ToString(CultureInfo.InvariantCulture) + ")";
        }
        break;

    case PrimitiveTypeKind.DateTime:
        typeName = SqlVersionUtils.IsPreKatmai(sqlVersion) ? "datetime" : "datetime2";
        break;
    case PrimitiveTypeKind.Time:
        AssertKatmaiOrNewer(sqlVersion, primitiveTypeKind);
        typeName = "time";
        break;
    case PrimitiveTypeKind.DateTimeOffset:
        AssertKatmaiOrNewer(sqlVersion, primitiveTypeKind);
        typeName = "datetimeoffset";
        break;

    case PrimitiveTypeKind.Decimal:
        if (!storeTypeUsage.MustFacetBeConstant(DbProviderManifest.PrecisionFacetName))
        {
            hasFacet = storeTypeUsage.TryGetPrecision(out decimalPrecision);
            Debug.Assert(hasFacet, "decimal must have precision facet");
            Debug.Assert(decimalPrecision > 0, "decimal precision must be greater than zero");
            hasFacet = storeTypeUsage.TryGetScale(out decimalScale);
            Debug.Assert(hasFacet, "decimal must have scale facet");
            Debug.Assert(decimalPrecision >= decimalScale, "decimalPrecision must be greater or equal to decimalScale");
            typeName = typeName + "(" + decimalPrecision + "," + decimalScale + ")";
        }
        break;

    default:
        break;
}

return typeName;

}


3. class SqlProviderServices

//

// Determines SqlDbType for the given primitive type. Extracts facet // information as well. // private static SqlDbType GetSqlDbType( TypeUsage type, bool isOutParam, SqlVersion version, out int? size, out byte? precision, out byte? scale, out string udtName) { // only supported for primitive type var primitiveTypeKind = ((PrimitiveType)type.EdmType).PrimitiveTypeKind;

size = default(int?);
precision = default(byte?);
scale = default(byte?);
udtName = default(string);

// CONSIDER(CMeek):: add logic for Xml here
switch (primitiveTypeKind)
{
    case PrimitiveTypeKind.Binary:
        // for output parameters, ensure there is space...
        size = GetParameterSize(type, isOutParam);
        return GetBinaryDbType(type);

    case PrimitiveTypeKind.Boolean:
        return SqlDbType.Bit;

    case PrimitiveTypeKind.Byte:
        return SqlDbType.TinyInt;

    case PrimitiveTypeKind.Time:
        if (!SqlVersionUtils.IsPreKatmai(version))
        {
            precision = GetKatmaiDateTimePrecision(type, isOutParam);
        }
        return SqlDbType.Time;

    case PrimitiveTypeKind.DateTimeOffset:
        if (!SqlVersionUtils.IsPreKatmai(version))
        {
            precision = GetKatmaiDateTimePrecision(type, isOutParam);
        }
        return SqlDbType.DateTimeOffset;

    case PrimitiveTypeKind.DateTime:
        //For katmai pick the type with max precision which is datetime2
        if (!SqlVersionUtils.IsPreKatmai(version))
        {
            precision = GetKatmaiDateTimePrecision(type, isOutParam);
            return SqlDbType.DateTime2;
        }
        else
        {
            return SqlDbType.DateTime;
        }

    case PrimitiveTypeKind.Decimal:
        precision = GetParameterPrecision(type, null);
        scale = GetScale(type);
        return SqlDbType.Decimal;

    case PrimitiveTypeKind.Double:
        return SqlDbType.Float;

    case PrimitiveTypeKind.Geography:
        {
            udtName = "geography";
            return SqlDbType.Udt;
        }

    case PrimitiveTypeKind.Geometry:
        {
            udtName = "geometry";
            return SqlDbType.Udt;
        }

    case PrimitiveTypeKind.Guid:
        return SqlDbType.UniqueIdentifier;

    case PrimitiveTypeKind.Int16:
        return SqlDbType.SmallInt;

    case PrimitiveTypeKind.Int32:
        return SqlDbType.Int;

    case PrimitiveTypeKind.Int64:
        return SqlDbType.BigInt;

    case PrimitiveTypeKind.SByte:
        return SqlDbType.SmallInt;

    case PrimitiveTypeKind.Single:
        return SqlDbType.Real;

    case PrimitiveTypeKind.String:
        size = GetParameterSize(type, isOutParam);
        return GetStringDbType(type);

    default:
        Debug.Fail("unknown PrimitiveTypeKind " + primitiveTypeKind);
        return SqlDbType.Variant;
}

}

ImGonaRot commented 6 years ago

Similar posts here but it is marked as "closed / won't fix". https://github.com/aspnet/EntityFramework6/issues/49 https://github.com/aspnet/EntityFramework6/issues/325

Can this be looked at again since this is a "breaking" change in SQL 2016?

There is also another problem with code generated from EF since it creates "DateTime" columns for tables yet compares as "DateTime2" data types.

SocSoftSpb commented 6 years ago

Also, need supports for Date datatype (without time).

ImGonaRot commented 6 years ago

@Gluck1312 Are you asking the EF team to support Date datatype without time? If so, what .NET data type would it bind too? I don't think there is a .NET data type for just "Date".

SocSoftSpb commented 6 years ago

Selecting a parameter type (for example, For the Insert command) should refer to ColumnType specified in the mapping (or by attribute):

modelBuilder.Entity<MyEntity>()
                    .Property(p => p.MyDate)
                    .HasColumnType("date");

If ColumnType is not specified, use the most common type (datetime or datetime2).

ajcvickers commented 6 years ago

@ImGonaRot Changing the default mapping would be a breaking change for many applications, so we can't do that. If we could come up with a way to workaround the break in SQL Server without breaking existing applications that use EF, then we would be open to doing that. As of now I am not aware of any way we can do it.

ImGonaRot commented 6 years ago

@ajcvickers I thought I showed you a way of fixing this by using "TypeName" attributes like EF Core uses. I even highlighted all the code that needs changed.

ajcvickers commented 6 years ago

@ImGonaRot Pretty sure none of that will fix this in a non-breaking way.

ImGonaRot commented 6 years ago

@ajcvickers Sure it could. Any place where it switches to DateTime2, check the .NET property attribute and (If and Only If) the user added "TypeName=DateTime" then let EF use DateTime rather than DateTime2.

Ex.

if (typeKind == PrimitiveTypeKind.DateTime)
    {
        // Check the "TypeName" attribute for DateTime
        // property.GetCustomAttributes(typeof(Column))... more code here
        if (typeNameAttribute == "DateTime")
        {
            typeName = "datetime";
        }
        else
        {
            typeName = sqlgen.IsPreKatmai ? "datetime" : "datetime2";
        }
    }

I would suggest caching the attributes so that we don't have to use reflection every time since it is slow.

ajcvickers commented 6 years ago

@ImGonaRot I'll take another look.

adolfo1981 commented 6 years ago

Please keep us posted. The company where I work is anxious to move to SQL 2017 but this has become a major roadblock. We can't run in compatibility mode indefinitely.

ImGonaRot commented 6 years ago

@adolfo1981 If you download the source code and change all the places I listed above from "datetime2" to "datetime" and recompile, it will work. This is assuming you are not using "datetime2" anywhere in SQL.

adolfo1981 commented 6 years ago

@ImGonaRot i have a few questions regarding your proposed fix:

  1. By default DateTime properties will be treated as "datetime" type unless user decorates them with [Column(TypeName ="datetime2")]. Is this correct? Even query parameters.
  2. From your comment above: " This is assuming you are not using "datetime2" anywhere in SQL." Couldn't we just decorate the datetime2 properties with TypeName = "datetime2"?
  3. With your fix is the Column(TypeName attribute defining the type of query parameter values? And if so, is this attribute the only supported configuration? or would it also work if we set the type through Fluent entity mapping configuration? Thanks in advance,
ImGonaRot commented 6 years ago

@adolfo1981

  1. While it would be nice to default to DateTime, I think that by default the EF team will need to use DateTime2 as the default attribute decorate when nothing is supplied since that is already in production and all code base. The developers using EF will need to apply the DateTime attribute when the don't want it to default to DateTime2.

  2. I think you miss-understood by comment. I was saying that if you are not using DateTime2 in SQL you can download the source code from github, change all the places I mentioned at the top of the post from datetime2 to datetime, recompile, and it will work with SQL 2016.

  3. I have no fix, only a suggestion to the EF team.

Currently I recompiled the code base for DateTime and am using it in our production system with SQL 2016. We have developers at our company actively working on converting our code from EF to EF Core since it does not have this problem.

stasones commented 5 years ago

I also faced with the issue and spent a lot of time on it. For fix that on application data access layer you can use command interceptor:

    /// <summary>
    /// DateTimeInterceptor fixes the incorrect behavior of Entity Framework library when for datetime columns it's generating datetime2(7) parameters 
    /// when using SQL Server 2016 and greater.
    /// Because of that, there were date comparison issues.
    /// Links:
    /// https://github.com/aspnet/EntityFramework6/issues/49
    /// https://github.com/aspnet/EntityFramework6/issues/578
    /// Notes:
    /// Disable it if:
    /// 1) Database DateTime types will be migrating to DateTime2
    /// 2) Entity Framework team will fix the problem in a future version
    /// </summary>
    public class DateTimeInterceptor : IDbCommandInterceptor
    {
        public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
            ChangeDateTime2ToDateTime(command);
        }

        public void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
        {
        }

        public void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
        {
        }

        public void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
        }

        public void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
        {
        }

        public void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
        {
        }

        private static void ChangeDateTime2ToDateTime(DbCommand command)
        {
            command.Parameters
                .OfType<SqlParameter>()
                .Where(p => p.SqlDbType == SqlDbType.DateTime2)
                .Where(p => p.Value != DBNull.Value)
                .Where(p => p.Value is DateTime)
                .Where(p => p.Value as DateTime? != DateTime.MinValue)
                .ToList()
                .ForEach(p => p.SqlDbType = SqlDbType.DateTime);
        }
    }

Don't use it if your database has DateTime2 columns too.

Hope this will help someone :)

adolfo1981 commented 5 years ago

@stasones I just tested the interceptor and worked fine. Clever solution. In our case we do have few columns that are datetime2 in our database but we could get creative and selectively apply the ChangeDateTime2ToDateTime logic. Thanks for sharing this workaround!

stasones commented 5 years ago

@adolfo1981 You are welcome. I have updated the query: there was an exception when the value of the parameter is DateTime.MinValue.

okarpov commented 5 years ago

specifying modelBuilder.Entity<Log>().Property(p => p.Date).HasColumnType("datetime2").HasPrecision(7);

or [Column(TypeName = "datetime2")]

still produce: ((convert (datetime2, convert(varchar(255), [Extent1].[Date], 102) , 102)) >= @plinq0)

how to avoid it?

dgenezini commented 5 years ago

Is there any other workaround? Tried the workarounds here and none work for only one DbContext. We have a legacy system in Delphi so we can't convert datetime fields to datetime2 without updating the drivers, which is a BIG change. HasColumnType("datetime") would solve.

adolfo1981 commented 5 years ago

Is there any other workaround? Tried the workarounds here and none work for only one DbContext. We have a legacy system in Delphi so we can't convert datetime fields to datetime2 without updating the drivers, which is a BIG change. HasColumnType("datetime") would solve.

@dgenezini Did you try the Interceptor solution from @stasones ? That sounds promising to me and worked in my prototype.

dbrownems commented 5 years ago

Following @stasones lead, I have a more sophisticated version that asks SQL Server to suggest the parameter type mapping, and overrides EFs type mapping where the correct parameter type should be DateTime, DateTime2, or Date.

It uses the sp_describe_undeclared_parameters to get the parameter types, and assembles a cache of parameter type recommendations to reuse for subsequent executions of the same query text.

Listing Follows:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data;
using System.Data.Common;
using System.Data.Entity;
using System.Data.Entity.Infrastructure.Interception;
using System.Data.SqlClient;
using System.Linq;

namespace ef6DatetimeWorkaround
{
    class Foo
    {
        public int Id { get; set; }

        [Column(TypeName = "datetime")]
        public DateTime? DateTimeColumn { get; set; }

        [Column(TypeName = "datetime2")]
        public DateTime? DateTime2Column { get; set; }

        [Column(TypeName = "date")]
        public DateTime? DateColumn { get; set; }

    }

    public class DbConfig : DbConfiguration
    {
        public DbConfig()
        {
            this.SetDatabaseInitializer(new DropCreateDatabaseAlways<Db>());
            this.AddInterceptor(new DateTimeParameterFixer());
        }
    }

    [DbConfigurationType(typeof(DbConfig))]
    class Db : DbContext
    {
        public DbSet<Foo> Foos { get; set; }
    }
    class Program
    {

        static void Main(string[] args)
        {
            DateTime? now = DateTime.Now;

            using (var db = new Db())
            {
                db.Database.Initialize(true);
                var f = new Foo()
                {
                    DateColumn = now,
                    DateTime2Column = now,
                    DateTimeColumn = now
                };
                db.Foos.Add(f);
                db.SaveChanges();
            }
            using (var db = new Db())
            {
                db.Configuration.UseDatabaseNullSemantics = false;
                db.Database.Log = s => Console.WriteLine(s);

                var q = from f in db.Foos
                        where f.DateColumn == now
                        where f.DateTime2Column == now
                        where f.DateColumn == now
                        select f;

                var results = q.Single();

                Console.WriteLine("Success");

            }

        }
    }

    public class DateTimeParameterFixer : IDbCommandInterceptor
    {
        public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
            var dbContexts = interceptionContext.DbContexts.ToList();

            if (dbContexts.Count == 1)
            {
                FixDatetimeParameters(dbContexts[0], command);
            }

        }

        public void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
        {
        }

        public void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
        {
        }

        public void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
        }

        public void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
        {
        }

        public void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
        {
        }

        private class SuggestedParameterType
        {
            public int parameter_ordinal { get; set; }
            public string name { get; set; }
            public int suggested_system_type_id { get; set; }
            public string suggested_system_type_name { get; set; }
            public Int16 suggested_max_length { get; set; }
            public byte suggested_precision { get; set; }
            public byte suggested_scale { get; set; }

        }

        private static ConcurrentDictionary<string, List<SuggestedParameterType>> batchCache = new ConcurrentDictionary<string, List<SuggestedParameterType>>();

        enum SqlTypeId
        {
            date = 40,
            datetime = 61,
            datetime2 = 42,
        }

        private static List<SuggestedParameterType> GetSuggestedParameterTypes(DbContext db, string batch, int parameterCount)
        {
            if (parameterCount == 0)
            {
                return new List<SuggestedParameterType>();
            }
            var con = (SqlConnection)db.Database.Connection;
            var conState = con.State;

            if (conState != ConnectionState.Open)
            {
                db.Database.Connection.Open();
            }
            var results = batchCache.GetOrAdd(batch, (sqlBatch) =>
            {
                var pBatch = new SqlParameter("@batch", SqlDbType.NVarChar, -1);
                pBatch.Value = batch;

                var rd = new List<SuggestedParameterType>();
                var cmd = new SqlCommand("exec sp_describe_undeclared_parameters @batch; ", con);
                cmd.Transaction = (SqlTransaction)db.Database.CurrentTransaction?.UnderlyingTransaction;

                cmd.Parameters.Add(pBatch);

                //sp_describe_undeclared_parameters does not support batches that contain multiple instances of the same parameter
                //
                //to workaround a common cause loop and transform on error expressions like:
                //
                // WHERE ([Extent1].[Date_Modified] = @p__linq__0) OR (([Extent1].[Date_Modified] IS NULL) AND (@p__linq__0 IS NULL))'
                //into
                // WHERE ([Extent1].[Date_Modified] = @p__linq__0) OR (([Extent1].[Date_Modified] IS NULL) AND (1=1))'
                // 
                // this works because the @param is null expression is irrelevant to the parameter type discovery.
                while (true)
                {
                    try
                    {
                        using (var rdr = cmd.ExecuteReader())
                        {
                            while (rdr.Read())
                            {
                                var sp = new SuggestedParameterType()
                                {
                                    //https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-describe-undeclared-parameters-transact-sql
                                    parameter_ordinal = rdr.GetInt32(0),
                                    name = rdr.GetString(1),
                                    suggested_system_type_id = rdr.GetInt32(2),
                                    suggested_system_type_name = rdr.GetString(3),
                                    suggested_max_length = rdr.GetInt16(4),
                                    suggested_precision = rdr.GetByte(5),
                                    suggested_scale = rdr.GetByte(6),
                                };

                                if (sp.suggested_system_type_id == (int)SqlTypeId.date || sp.suggested_system_type_id == (int)SqlTypeId.datetime2 || sp.suggested_system_type_id == (int)SqlTypeId.datetime)
                                {
                                    if (!sp.name.EndsWith("IgNoRe"))
                                    {
                                      rd.Add(sp);
                                    }
                                }
                            }
                            break;
                        }
                    }
                    catch (SqlException ex) when (ex.Errors[0].Number == 11508)
                    {
                        //Msg 11508, Level 16, State 1, Line 14
                        //The undeclared parameter '@p__linq__0' is used more than once in the batch being analyzed.
                        var paramName = System.Text.RegularExpressions.Regex.Match(ex.Errors[0].Message, "The undeclared parameter '(?<paramName>.*)' is used more than once in the batch being analyzed.").Groups["paramName"].Value;

                        string sql = (string)pBatch.Value;
                        if (sql.Contains($"{paramName} IS NULL"))
                        {
                            sql = sql.Replace($"{paramName} IS NULL", "1=1");
                            pBatch.Value = sql;
                            continue;
                        }
                        else
                        {
                            throw;
                        }

                    }

                }
                return rd;

            });

            if (conState == ConnectionState.Closed)
            {
                con.Close();
            }
            return results;

        }

        private static void FixDatetimeParameters(DbContext db, DbCommand command)
        {
            if (!command.Parameters.OfType<SqlParameter>().Any(p => p.SqlDbType == SqlDbType.DateTime2 || p.SqlDbType == SqlDbType.DateTime))
            {
                return;
            }
            var suggestions = GetSuggestedParameterTypes(db, command.CommandText, command.Parameters.Count);

            if (suggestions.Count == 0)
            {
                return;
            }

            Dictionary<string, SqlParameter> paramLookup = new Dictionary<string, SqlParameter>();
            foreach (var param in command.Parameters.OfType<SqlParameter>())
            {
                if (param.ParameterName[0] == '@')
                {
                    paramLookup.Add(param.ParameterName, param);
                }
                else
                {
                    paramLookup.Add("@" + param.ParameterName, param);
                }
            }
            foreach (var suggestion in suggestions)
            {
                var param = paramLookup[suggestion.name];

                if (suggestion.suggested_system_type_id == (int)SqlTypeId.datetime2)
                {
                    param.SqlDbType = SqlDbType.DateTime2;
                    param.Scale = suggestion.suggested_scale;
                }
                else if (suggestion.suggested_system_type_id == (int)SqlTypeId.datetime)
                {
                    param.SqlDbType = SqlDbType.DateTime;
                }
                else if (suggestion.suggested_system_type_id == (int)SqlTypeId.date)
                {
                    param.SqlDbType = SqlDbType.Date;
                }

            }
        }
    }
}
adolfo1981 commented 5 years ago

@dbrownems I tried your custom interceptor but I get an error when running the sp_describe_undeclared_parameters stored procedure when parameters are used more than once:

exec sp_describe_undeclared_parameters @tsql =N'SELECT 
    [Extent1].[m_uid] AS [m_uid], 
    [Extent1].[m_eff_start_date] AS [m_eff_start_date], 
    [Extent1].[ClientID] AS [ClientID], 
    [Extent1].[m_eff_end_date] AS [m_eff_end_date], 
    [Extent1].[ProcessID] AS [ProcessID], 
    [Extent1].[Status] AS [Status], 
    [Extent1].[UserID] AS [UserID], 
    [Extent1].[Date_Created] AS [Date_Created], 
    [Extent1].[Date_Modified] AS [Date_Modified], 
    [Extent1].[PlannerUID] AS [PlannerUID], 
    [Extent1].[EmployeeUID] AS [EmployeeUID]
    FROM [dbo].[ProcessStatus] AS [Extent1]
    WHERE ([Extent1].[Date_Modified] = @p__linq__0) OR (([Extent1].[Date_Modified] IS NULL) AND (@p__linq__0 IS NULL))'

I get this error: The undeclared parameter '@plinq0' is used more than once in the batch being analyzed.

I tried to fix it based on this post: https://raresql.com/2013/05/22/sql-server-2012-fix-error-11508-the-undeclared-parameter-is-used-more-than-once-in-the-batch-being-analyzed/

But I get 0 results back from the sproc:

exec sp_describe_undeclared_parameters @tsql =N'SELECT 
    [Extent1].[m_uid] AS [m_uid], 
    [Extent1].[m_eff_start_date] AS [m_eff_start_date], 
    [Extent1].[ClientID] AS [ClientID], 
    [Extent1].[m_eff_end_date] AS [m_eff_end_date], 
    [Extent1].[ProcessID] AS [ProcessID], 
    [Extent1].[Status] AS [Status], 
    [Extent1].[UserID] AS [UserID], 
    [Extent1].[Date_Created] AS [Date_Created], 
    [Extent1].[Date_Modified] AS [Date_Modified], 
    [Extent1].[PlannerUID] AS [PlannerUID], 
    [Extent1].[EmployeeUID] AS [EmployeeUID]
    FROM [dbo].[ProcessStatus] AS [Extent1]
    WHERE ([Extent1].[Date_Modified] = @p__linq__0) OR (([Extent1].[Date_Modified] IS NULL) AND (@p__linq__0 IS NULL))'
    ,@params =  N'@p__linq__0 datetime2'

Any ideas? My test EF query is very simple:

ProcessStatusEntity processStatus = context.ProcessStatuses.First();
var result = context.ProcessStatuses.Where(p => p.DateModified == processStatus.DateModified).ToList();

I even tried hard-coding the @params in your code but got back 0 records from the sproc:

            var results = batchCache.GetOrAdd(batch, (sqlBatch) =>
            {
                var sql = "exec sp_describe_undeclared_parameters @batch,@params; ";
                var pBatch = new SqlParameter("@batch", SqlDbType.NVarChar, -1);
                pBatch.Value = batch;
                var pParams = new SqlParameter("@params", SqlDbType.NVarChar, -1);
                pParams.Value = "@p__linq__0 datetime2";

                var rd = new List<SuggestedParameterType>();
                var cmd = new SqlCommand(sql, con);
dbrownems commented 5 years ago

@adolfo1981 I've updated the workaound to strip '@plinq0 IS NULL' constructs from the batch if it encounters this error.

cblaze22 commented 5 years ago

Just ran into this issue by updating the compatibility of SQLServer to 130. How is there not a fix with entity framework and this yet?

dgenezini commented 5 years ago

@dgenezini Did you try the Interceptor solution from @stasones ? That sounds promising to me and worked in my prototype.

@adolfo1981 , yes, just made a little change to check for a Attribute so I can have both scenarios simultaneously.

    public class DateTimeInterceptor : IDbCommandInterceptor
    {
        public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
            var DbContext = interceptionContext.DbContexts.FirstOrDefault();

            if ((DbContext != null) &&
                (Attribute.IsDefined(DbContext.GetType(), typeof(ChangeDateTime2ToDateTimeAttribute))))
            {
                ChangeDateTime2ToDateTime(command);
            }
        }

        ...
    }

Still, I believe that the [Column(TypeName ="datetime")] attribute should be considered by entity framework.

adolfo1981 commented 5 years ago

@dgenezini Did you try the Interceptor solution from @stasones ? That sounds promising to me and worked in my prototype.

@adolfo1981 , yes, just made a little change to check for a Attribute so I can have both scenarios simultaneously.

    public class DateTimeInterceptor : IDbCommandInterceptor
    {
        public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
        {
            var DbContext = interceptionContext.DbContexts.FirstOrDefault();

            if ((DbContext != null) &&
                (Attribute.IsDefined(DbContext.GetType(), typeof(ChangeDateTime2ToDateTimeAttribute))))
            {
                ChangeDateTime2ToDateTime(command);
            }
        }

        ...
    }

Still, I believe that the [Column(TypeName ="datetime")] attribute should be considered by entity framework.

So you had to decorate all your applicable DateTime properties with the ChangeDateTime2ToDateTimeAttribute? Oh or you put that attribute at the DbContext level instead?

dgenezini commented 5 years ago

So you had to decorate all your applicable DateTime properties with the ChangeDateTime2ToDateTimeAttribute? Oh or you put that attribute at the DbContext level instead?

I'm decorating the DbContexts, in my case all DateTime properties of the database will be DATETIME.

dbrownems commented 5 years ago

@dgenezini No. The attribute would be on the DbContext type, not the individual properties. In the interceptor we have no information about what entity property the command parameters are bound to, so the we can't configure on a property-by-property basis. If we could, that would be a better solution.

RtypeStudios commented 4 years ago

We have come across a issue along these lines too.

When field in the database is set to Date, any querying against the field is done with a parameter type of "DateTime2" rather than "Date" this prevents us from being able to intercept date fields correctly in the interceptor.

I second being able to define the column type explicitly and be able to also add date as a defined type.

[Column(TypeName ="datetime2")] public DateTime TestDate { get; set; }

[Column(TypeName ="datetime")] // Not needed by default public DateTime AnotherTestDate { get; set; }

[Column(TypeName ="date")] // Not needed by default public DateTime AnotherTestDate { get; set; }

KumG commented 4 years ago

So this is still not fixed in the last versions?

The interceptor workaround doesn't work.

ChuckBorris commented 3 years ago

The problem is, that EF6 does not work consistantly with datetime / datetime2(7) fields. Ok, we have datetime in database, that one is true. But if EF6 writes (SaveChanges) as datetime2 it should also read the column as datetime2. If EF6 would be consistant, everything would be ok...

Examlpe: exec sp_executesql N'UPDATE [dbo].[setup] SET [modified] = @0, [text] = @1 WHERE (([mykey] = @2) AND ([modified] = @3)) ',N'@0 datetime2(7),@1 varchar(max) ,@2 char(2),@3 datetime2(7)' ,@0='2021-06-09 12:54:46.7531175',@1='20000',@2='02',@3='2021-06-09 11:30:33.1670000'

Parameter @3 is wrong.

select modified, convert(datetime2(7),modified) as dt2 from dbo.setup where mykey='02'

datetime column in DB = 2021-06-09 11:30:33.167, converted to datetime2(7) in DB = 2021-06-09 11:30:33.1666667

in debug I can see that the DateTime object(modified) in Class (setup) has property TimeOfDay = 2021-06-09 11:30:33.1670000. So... If EF6 asumes all DateTime objects(columns) are of type "datetime2" it should also read the column as such. There is no way (as far as I know there is no way how to intercept SaveChanges and adjust value or column (ie.: convert(datetime2(7),modified = @3 ). The only solution is to change compatibility level down to 120 or to change the datetime to datetime2 in database. The second one is dangerous if we other code where we have datetime column (temporary tables, stored procedures etc...)

ajcvickers commented 2 years ago

This issue has been closed because EF6 is no longer being actively developed. We are instead focusing on stability of the codebase, which means we will only make changes to address security issues. See the repo README for more information.

softlion commented 6 months ago

Still having this issue in EF8

ChuckBorris commented 6 months ago

The code, that works for me (I use code first approach):

`

 public class MyContext : DbContext, IDbModelCacheKeyProvider
 {
      public override int SaveChanges()
      {
           // I manually add value to concurrency column (foreach table, that was modified and has concurrency column)
           foreach (DbEntityEntry entry in GetChangedEntries())
           {
                if (entry.State == EntityState.Modified)
                {
                     //checking if there is concurrency column
                     PropertyInfo entityProperty = entry.Entity.GetType().GetProperty("modifiedColumnName");                          if (entityProperty != null)
                     {
                          // instead of DateTime.Now, I use new SqlDateTime(DateTime.Now).Value  for concurrency column!!!
                          entityProperty.SetValue(entry.Entity, new SqlDateTime(DateTime.Now).Value), null);
                     }          
                }
           }
           base.SaveChanges();
      }

      protected IEnumerable<DbEntityEntry> GetChangedEntries()
      {
               return new List<DbEntityEntry>(
                   from e in ChangeTracker.Entries()
                   where e.State != EntityState.Unchanged
                   select e);
      }

      // for each table. If it has "modifiedColumnName"  then I set it as coucurrencyToken manualy
      protected override void OnModelCreating(DbModelBuilder modelBuilder)
      {
           var props = modelBuilder.Properties().Where(o => o.Name == "modifiedColumnName");
           props.Configure(o => o.IsConcurrencyToken(true));
      }
 }`