2

I seem to have a problem with EF core and my MariaDb database. First of all, I'm unable to do the obvious move to change the database a bit, so that option is off the table.

I have a table "Contacts" where a contact date is being kept. This contact date is being kept in the form of a string value, containing only the date, as such: 2020-08-30 for 30th august of 2020. I have an EF core mapping for this field as such:

entity.Property(e => e.ContactDate)
                    .IsRequired()
                    .HasColumnName("contactDate")
                    .HasColumnType("varchar(255)")

where e.ContactDate is an DateTime property.

When I use the property in my code, the datetime works as expected and contains the date saved in the databank. hooray!

The problem however arises when I want to query on that datetime. Given this query:

SELECT `c7`.`contactDate`
    FROM `contacts` AS `c7`
    WHERE (`f`.`uuid` = `c7`.`fileUuid`) AND (`c7`.`numberOfContacts` > 0)
    ORDER BY `c7`.`contactDate`
    LIMIT 1) <= @__endDate_1) AND (`f`.`closingDate` IS NULL OR ((`f`.`closingDate` >= @__startDate_2)

The properties startDate and endDate are inserted into the query as parameters. For example, @__endDate_1='2019-12-31 00:00:00'. And here lies the problem: MySql is going to string compare the contactDate db field with the string value of that endDate. Because one has a time field and the other one does not, I run into problems if I want to do a greather than function. (SELECT "2020-02-04" >= "2020-02-04 00:00:00" returns 0).

Is there a way that i can:

  • Or change EF that he casts the contactDate to a value with a timestamp in every query
  • Or change EF that he casts the DateTime value to a Date Value without any time values?

What do you guys feel about a possible third solution where I introduce a new "Date" class as a DateTime to Date string wrapper for EF?

Thanks!

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
John Verbiest
  • 349
  • 2
  • 15
  • Is your query written in your code using EF? Can you please share it? – Alireza Mahmoudi Sep 05 '20 at 06:31
  • @JohnVerbiest BTW, are you using [Pomelo.EntityFrameworkCore.MySql](https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql) as the EF Core provider to access your MariaDB database? – lauxjpn Sep 05 '20 at 23:43
  • Why go to so much trouble to work around a fundamental data modelling error in the first place? Why not just fix the error by creating a new column of a suitable date/datetime type and populate it as a one time op, and query on that, perhaps providing a computed string column equivalent etc to allow old software to carry on reading while you get around to updating it? – Caius Jard Sep 09 '20 at 15:43
  • @AlirezaMahmoudi The query was written using EF, like this: contactRepo.Where(x => x.ContactDate < new DateTime(2020,01,03,5,1,3)).Take(10) – John Verbiest Sep 09 '20 at 20:38
  • @lauxjpn Yes I used the pomelo provider, as the mysql alternative is shit :) – John Verbiest Sep 09 '20 at 20:38
  • @CaiusJard I have to work around the fundamental modelling error because as I stated in my question, I can't change the model :) – John Verbiest Sep 09 '20 at 20:39

4 Answers4

2

With

.HasColumnType("varchar(255)")

you are activating the EF Core built-in DateTime to string value converter. It's not documented, but it converts the DateTime values (when binding command parameters) to string using the following format (or similar)

"yyyy\\-MM\\-dd HH\\:mm\\:ss.FFFFFFF"

while what you need is a format like this

"yyyy\\-MM\\-dd"

It can be achieved by configuring a custom value converter:

entity.Property(e => e.ContactDate)
    .HasConversion(
        value => value.ToString("yyyy\\-MM\\-dd"),
        dbValue => DateTime.Parse(dbValue, CultureInfo.InvariantCulture)
    )
    // the rest is the same
    .IsRequired()
    .HasColumnName("contactDate")
    .HasColumnType("varchar(255)"); // or .IsUnicode(false).HasMaxLength(255)

And that's it. Now the parameter in question will be @__endDate_1='2019-12-31', hence this achieves the bullet #2.

Bullet #1 is not possible.

What about creating special Date type, it's possible, but not easy, since it requires a lot of infrastructure plumbing code. There are some packages which do that - NodaTime, NetTopology etc. You can look at their implementation if you are interested, but IMHO supporting (easy) custom value types is not EF Core priority (currently), so until they get better support, I would stay with value converters.

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
  • I would have suggested the same approach. BTW, bullet point #1 _is_ possible to achieve in multiple ways with reasonable effort, but the approach you provided should be preferable in the majority of cases, because it is simpler. @JohnVerbiest: If you _do_ want to go for a bullet point #1 approach for some reason, just state so in a comment. – lauxjpn Sep 05 '20 at 23:41
  • I'm sorry but this approach did not fix my issue. The fault that I described remained within this solution. – John Verbiest Sep 09 '20 at 20:37
  • 2
    @JohnVerbiest It _does_ work. I posted an answer that proves that it works as expected. – lauxjpn Sep 10 '20 at 01:44
  • I'm pretty sure it works. Because I've tested it, @lauxjpn (the lead of development of the used library) confirmed that, and also your own solution uses exactly the same technique (`ValueConverter`), just applied to custom type rather than standard `DateTime`. It's your right of deciding what path to take, but not to do wrong claims. – Ivan Stoev Sep 10 '20 at 12:48
  • 1
    I'm sorry, I must have done something wrong then. I'm glad I did find a solution in time for my deadline, but will try this solution later on when I'm touching this code again. – John Verbiest Sep 10 '20 at 22:04
2

The answer of @IvanStoev does work as requested and as expected.

Here is a fully working sample console program, that proves it works:

using System;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Pomelo.EntityFrameworkCore.MySql.Storage;

namespace IssueConsoleTemplate
{
    public class Contact
    {
        public int ContactId { get; set; }
        public DateTime ContactDate { get; set; }
    }

    public class Context : DbContext
    {
        public virtual DbSet<Contact> Contacts { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                .UseMySql("server=127.0.0.1;port=3306;user=root;password=;database=So63655418",
                    b => b
                        .ServerVersion(new ServerVersion("8.0.20-mysql")))
                .UseLoggerFactory(LoggerFactory.Create(b => b
                    .AddConsole()
                    .AddFilter(level => level >= LogLevel.Information)))
                .EnableSensitiveDataLogging()
                .EnableDetailedErrors();
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Contact>(
                entity =>
                {
                    entity.Property(e => e.ContactDate)
                        .HasColumnType("varchar(255)")
                        .HasConversion(
                            v => v.ToString(@"yyyy\-MM\-dd"),
                            v => DateTime.Parse(v, CultureInfo.InvariantCulture));

                    entity.HasData(
                        new Contact
                        {
                            ContactId = 1,
                            ContactDate = new DateTime(2020, 9, 1, 14, 42, 59, 123), // <-- time will be dropped
                        });
                });
        }
    }

    internal static class Program
    {
        private static void Main()
        {
            using var context = new Context();

            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
            
            var dateParameter = new DateTime(2020, 9, 1, 21, 11, 1, 456); // <-- time will be dropped
            var contactWithExactDate = context.Contacts.SingleOrDefault(c => c.ContactDate == dateParameter);
            
            Debug.Assert(contactWithExactDate != null);
            Debug.Assert(contactWithExactDate.ContactDate == new DateTime(2020, 9, 1));
        }
    }
}

It generates the following SQL statements:

warn: Microsoft.EntityFrameworkCore.Model.Validation[10400]
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data, this mode should only be enabled during development.

info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 3.1.8 initialized 'Context' using provider 'Pomelo.EntityFrameworkCore.MySql' with options: ServerVersion 8.0.20 MySql SensitiveDataLoggingEnabled DetailedErrorsEnabled

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (77ms) [Parameters=[], CommandType='Text', CommandTimeout='30']

      DROP DATABASE `So63655418`;

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (11ms) [Parameters=[], CommandType='Text', CommandTimeout='30']

      CREATE DATABASE `So63655418`;

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (70ms) [Parameters=[], CommandType='Text', CommandTimeout='30']

      CREATE TABLE `Contacts` (
          `ContactId` int NOT NULL AUTO_INCREMENT,
          `ContactDate` varchar(255) NOT NULL,
          CONSTRAINT `PK_Contacts` PRIMARY KEY (`ContactId`)
      );

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (11ms) [Parameters=[], CommandType='Text', CommandTimeout='30']

      INSERT INTO `Contacts` (`ContactId`, `ContactDate`)
      VALUES (1, '2020-09-01');

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (8ms) [Parameters=[@__dateParameter_0='2020-09-01' (Nullable = false) (Size = 255)], CommandType='Text', CommandTimeout='30']

      SELECT `c`.`ContactId`, `c`.`ContactDate`
      FROM `Contacts` AS `c`
      WHERE `c`.`ContactDate` = @__dateParameter_0
      LIMIT 2

The program executes without assertion and the logged SQL statements show the used parameter correctly as @__dateParameter_0='2020-09-01', even though we explicitly used a time value in the C# code.

lauxjpn
  • 4,749
  • 1
  • 20
  • 40
0

When I was in the long wait for answers, I went ahead and tried the Date type approach, with it specific conversions. As a miracle, suddenly EF converted all the stored string date's into datetime objects in the query. It was marvelous and it did the job!

This is the code for my date object:

public struct Date: IComparable<Date>, IComparable<DateTime>, IComparable, IEquatable<Date>
    {
        
        /// <summary>
        /// Turns a DateTime into a Date
        /// </summary>
        /// <param name="dateTime"></param>
        public Date(DateTime dateTime)
        {
            _date = dateTime.Date;
        }

        private readonly DateTime _date;

        public static implicit operator Date(DateTime value) => new Date(value);
        public static implicit operator DateTime(Date value) => value._date;

        public int CompareTo(Date other) => _date.CompareTo(other._date);
        public int CompareTo(DateTime other) => _date.CompareTo(new Date(other));
        public int CompareTo(object obj) => _date.CompareTo(obj);
        public bool Equals(Date other) => _date.Equals(other._date);
        public override bool Equals(object obj) => obj is Date other && Equals(other);
        public override int GetHashCode() => _date.GetHashCode();
        
    }

And the conversion for EF was like this:

public static PropertyBuilder<Date?> HasDateConversion(this PropertyBuilder<Date?> propertyBuilder) =>
            propertyBuilder
                .HasConversion(date => 
                        date.HasValue
                        ? ParseDate(date.Value)
                        : null,
                    str =>
                        string.IsNullOrWhiteSpace(str) 
                            ? null
                            : (Date?)ParseString(str));

This resulted in all the date's selected to be selected as such:

SELECT * FROM `contacts` AS `c`
WHERE CAST(`c`.`contactDate` AS datetime(6)) < '2020-01-03 05:01:03.000000'
LIMIT 1

Thus my issue was fixed :) hooray!

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
John Verbiest
  • 349
  • 2
  • 15
  • 1
    Glad you figured out a way on your own to make it work. However, the answer posted by @IvanStoev _does_ work correctly. I posted an answer myself, that includes a fully working sample console program that demonstrates that it works as expected. – lauxjpn Sep 10 '20 at 01:46
  • 1
    I can see it, and I have given the answers now the respect they deserve. I needed a quick fix so I went ahead with this solution. I'll try the other one once I get a swing by that code again. – John Verbiest Sep 10 '20 at 22:06
  • No worries! There are multiple solutions to the issue. This question has now two working answers, which is nice if somebody with a similar problem finds this. It gives more context and a wider range of solutions. – lauxjpn Sep 10 '20 at 23:31
  • 1
    Removed the non essential and wrong parts of the answer. It definitely works, but slightly differently, and only due to the following C# expression sugar (sorry, no EF Core magic here :) - in order to compile, expression written as `e.ContactDate >= someDateTime` actually is `(DateTime)e.ContactDate >= someDateTime`. C# compiler inserts that implicit conversion cast for you in order to satisfy the comparison operator. That's why you get datetime cast inside the generated SQL plus datetime type parameter. Which is let say interesting way to modify the default EFC value conversion behavior. – Ivan Stoev Sep 11 '20 at 05:10
0

I would have left the format in the database as you wrote it. But I would have changed the query. In this case a cast of the date format like it was show in this thread:

MySQL compare DATE string with string from DATETIME field

SELECT * FROM contacts WHERE DATE(startTime) > '2020-08-30'

... or in the official documentation of MariaDB: DATETIME Syntax

abo
  • 131
  • 4