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
string
to aDateTime
within the LINQ query.DateTime.Parse(...) // or Convert.ToDateTime(...)
This will work if our underlying
IQueryable
provider 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.LeaseStart
This 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/2021
and 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 Converters
for 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
varchar
column?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
startSearch
parameter inside the LINQ query to be of typeDateTimeOffset
.It doesn’t use CAST if the parameter is
DateTime
as it simply converts your parameter tovarchar
and then compares. I made this gist to demo the behaviour.This is more of a hack as we are relying on implicit conversion of
DateTime
from/toDateTimeOffset
inside .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
DateTime
but 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/yyyy
by default. Which might not be ideal for non us based data.Just like option 2 it uses
CAST
and 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/yyyy
thenSET 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.DateFromParts
function 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.DateFromParts
has 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