Going down the rabbit hole of EF Core and converting strings to dates
I am working on a greenfield project that uses EF Core 6 with AspNetCore 6 at the moment. The project involves exposing a set of legacy data through an API. Simple enough right?
The underlying data is stored in SQL Server 2019 but it is not very well designed. There are varchar columns for storing boolean, numeric and date/time values. It’s not uncommon to see these types of data stores though. As developers we have to deal with them often.
Dapper or EF Core
When choosing the data access layer for the project I had the option to go with Dapper or EF Core. I choose to go with EF Core because this specific API had a lot of requirements around paging and sorting (See here for more). You can easily implement paging and sorting with Dapper too. But I find constructing paging and sorting dynamically using EF Core IQueryable more appealing than manipulating strings in Dapper. I will do another post about dynamic paging and sorting using EF Core soon.
But this choice comes with trade offs as with any technical decision. While I don’t have to “construct” SQL with string manipulation, an ORM comes at a cost of not being able to execute the exact SQL I want if I’m using IQueryable to construct my LINQ query. This is a hot topic when it comes to designing your data access layer but that is a topic for another post.
The Problem
Imagine the following schema for a table called CustomerLease.
| Column | Data Type | 
|---|---|
| LeaseId | int | 
| CustomerId | int | 
| LeasedItem | nvarchar(2000) NULL | 
| LeaseStart | nvarchar(10) | 
| LeaseEnd | nvarchar(10) NULL | 
We are required to find customer leases that started after a given date.
Now lets assume what we would do if the LeaseStart was DateTime .NET Type in my EF Core entity model for CustomerLease.
  public class CustomerLease
  {
    //... other fields
    DateTime LeaseStart {get; set;}
  }
  public class MyRepo {
      // constructor and other properties will go here...
      // example method to search within date periods
      public async Task<List<CustomerLease>> GetCustomerLeases(SearchRequest request) 
      {
          var searchFrom = request.SearchFrom;
          var query = MyDataContext.CustomerLeases
                  .Where(c => searchFrom <= c.LeaseStart);
          return await query.ToListAsync();      
      }  
  }
This solution would work if my underlying DB type was DateTime BUT it is not.
So my actual entity model looks like…
  public class CustomerLease
  {
    //... other fields
    string LeaseStart {get; set;}
  }
Now I can’t write my LINQ query with direct comparison to SearchFrom. What are my alternatives?
- Try converting the stringto aDateTimewithin the LINQ query.DateTime.Parse(...) // or Convert.ToDateTime(...)This will work if our underlying IQueryableprovider for SQL Server supported translating these functions to SQL. But unfortunately they aren’t. So this approach is out of the question.
- 
    Using implicit conversion . .Where(c => searchFrom <= (DateTime)(object)c.LeaseStartThis technique generates the following SQL. “ CAST([S].[LeaseStart] as DateTime) >= @__searchFrom__” This will work but word of caution. This double casting we have done in LINQ to trick the underlying provider to use CAST will only work for SQL Provider. It will not work for the In-Memory database provider if you’re using it for writing unit/integration tests.The other drawback here is that it expects the dates to be in the default format of the current session language. (i.e. US English, British English etc). If you have a date there like 24/05/2021and the the current language is US English then it will fail with a message like"The conversion of a varchar data type to a datetime data type resulted in an out-of-range value".I talk about this again below in option 3 and touch on some work arounds.
- 
    Using EF Core value converter. With EF Core 5+ you can use Value Convertersfor this scenario and there are built in ones for some common use cases.Be mindful that ValueConverters work inside .NET and not SQL. So how do we get it to do a CAST on our varcharcolumn?protected override void OnModelCreating(ModelBuilder modelBuilder) { // The column TextDate is the one that has date values but stored as text in the db modelBuilder .Entity<CustomerLease>() .Property(c => c.LeaseStart) .HasConversion<string>(); } public class CustomerLease { //... other fields DateTime LeaseStart {get; set;} }Then in LINQ simply do .Where(e => e.LeaseStart >= startSearch).Here is the kicker. For EF Core to generate the correct SQL statement, it will require startSearchparameter inside the LINQ query to be of typeDateTimeOffset.It doesn’t use CAST if the parameter is DateTimeas it simply converts your parameter tovarcharand then compares. I made this gist to demo the behaviour.This is more of a hack as we are relying on implicit conversion of DateTimefrom/toDateTimeOffsetinside .NET and then letting the EFCORE SQL Provider do a CAST when comparing inside SQL.The above LINQ will generate SQL like… DECLARE @__startSearch_0 datetimeoffset = '2022-01-22T23:01:43.0090270+11:00'; # and query like WHERE ((@__startSearch_0 <= CAST([s].[LeaseStart]) AS datetimeoffset))The only good things about the ValueConverter here is that it simply allows us to have the Entity Model field type as a DateTimebut doesn’t actually do anything when querying. You can remove the.HasConversion<string>()notation from the model builder and the logic for querying will still work regardless.Again this has the same draw back as option 2 even though it does work with In-Memory DB. If you read the value converters documentation page linked above it says the DateTime/String converter uses “Invariant Culture”. Which means it uses MM/dd/yyyyby default. Which might not be ideal for non us based data.Just like option 2 it uses CASTand is susceptible to the column having dates in a format that is different to the session’s language setting.For example if you have data in that text column in the form of dd/MM/yyyythenSET LANGUAGE "British English"before you execute your SQL query which has the CAST to avoid the"The conversion of a varchar data type to a datetime data type resulted in an out-of-range value"error. The default language can be set to the SQL login if you don’t want to execute the SET LANGUAGE command each time.
- 
    Using Custom SQL Translation. public static class ModelBuilderExtensions { public static DateTime? ToDateTime(this string dateString, int format) => throw new NotSupportedException(); public static ModelBuilder AddSqlConvertFunction(this ModelBuilder modelBuilder) { modelBuilder.HasDbFunction(() => ToDateTime(default, default)) .HasTranslation(args => new SqlFunctionExpression( functionName: "CONVERT", arguments: args.Prepend(new SqlFragmentExpression("date")), nullable: true, argumentsPropagateNullability: new[] { false, true, false }, type: typeof(DateTime), typeMapping: null)); return modelBuilder; } } // then on model creating protected override void OnModelCreating(ModelBuilder modelBuilder) { if (Database.IsSqlServer()){ modelBuilder.AddSqlConvertFunction(); } } // entity model public class CustomerLease { public string LeaseStart {get; set;} } // To query var dateFormat = 103; // See all date formats here https://www.w3schools.com/sql/func_sqlserver_convert.asp var query = db.Set<CustomerLease>() .Where(c => c.LeaseStart.ToDateTime(dateFormat) >= searchStart);This will result in a SQL query like below.. ((@__startSearch__ <= CONVERT(date, [s].[LeaseStart], 103);)This is a much more precise solution as we explicitly define the date format we want for the conversion. One of the drawbacks with this approach for me was that I couldn’t get this to work with In-Memory DB provider which I used for unit/integration tests. Your mileage may vary. 
- 
    Use the EF.Functions.DateFromParts(year, month, day)function.Here you write the query using EF.Functions.DateFromPartsfunction and pass the year, month and day in. This means you need to useLeaseStart.substring(x,x)to split extract each part and construct a proper date. I won’t write an example query here as the date formats will determine the substring start/end for each component.The drawback from this approach is again that EF.Functions.DateFromPartshas no translation in In-Memory DB.
- 
    Use the correct data type in SQL Server. Simple isn’t it? You just add a new column and map the current column with a CAST and populate the new one. For scenarios where you can’t, maybe you create a new view with the desired data types. Yes it has performance implications but it is another option to consider nevertheless. 
Conclusion
We learned that our data access layer tooling and abstractions come with trade offs. We also learnt that converting a string column type to date within a LINQ query is not trivial when it comes to EF Core SQL Provider.
Hopefully this gives you some options to try. While I can’t emphasise enough how important it is to have your underlying database column types represented in the correct data type sometimes we don’t have the option to change that. Not immediately anyway.
So I went back to the DBA and convinced them to change the underlying data type to reflect the correct type. This meant my entity model and LINQ query are much simpler and make sense in the domain.
Please let me know what you thought about this post and if you have other/better techniques to deal with this problem. Thanks for reading and have a great day.
References
- https://stackoverflow.com/questions/68728498/convert-string-to-datetime-in-linq-query-with-entity-framework-core
- https://stackoverflow.com/questions/60969027/how-to-convert-string-to-datetime-in-c-sharp-ef-core-query
- https://stackoverflow.com/questions/20838344/sql-the-conversion-of-a-varchar-data-type-to-a-datetime-data-type-resulted-in/40106812#40106812
- https://docs.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql?view=sql-server-ver15
- https://docs.microsoft.com/en-us/ef/core/providers/sql-server/functions
- https://docs.microsoft.com/en-us/ef/core/modeling/value-conversions
- https://docs.microsoft.com/en-us/sql/t-sql/statements/set-language-transact-sql?view=sql-server-ver15
 
       
     
     
     
    
Leave a comment