Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EF Core InMemory database throws an argument exception on Nullable<>.ToString #35299

Open
wilfriedb opened this issue Dec 9, 2024 · 10 comments · May be fixed by #35763
Open

EF Core InMemory database throws an argument exception on Nullable<>.ToString #35299

wilfriedb opened this issue Dec 9, 2024 · 10 comments · May be fixed by #35763
Assignees
Labels
area-in-memory area-query closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported regression type-bug
Milestone

Comments

@wilfriedb
Copy link

wilfriedb commented Dec 9, 2024

After upgrading to EF Core 9, a unit test using the InMemory database throws this exception:

System.ArgumentException
  HResult=0x80070057
  Message=Method 'System.String ToString(System.String)' declared on type 'System.DateOnly' cannot be called with instance of type 'System.Nullable`1[System.DateOnly]'
  Source=System.Linq.Expressions
  StackTrace:
   at System.Linq.Expressions.Expression.ValidateCallInstanceType(Type instanceType, MethodInfo method) in /_/src/libraries/System.Linq.Expressions/src/System/Linq/Expressions/MethodCallExpression.cs:line 1265

This is from the Microsoft.EntityFrameworkCore.InMemory package.

When I look into this package code where the exception originates, I see the following change between version 8 and version 9:

EF Core 8:
Namespace Microsoft.EntityFrameworkCore.InMemory.Query.Internal
InMemoryExpressionTranslatingExpressionVisitor.cs line 888:

        // if object is nullable, add null safeguard before calling the function
        // we special-case Nullable<>.GetValueOrDefault, which doesn't need the safeguard
        if (methodCallExpression.Object != null
            && @object!.Type.IsNullableType()
            && methodCallExpression.Method.Name != nameof(Nullable<int>.GetValueOrDefault))
        {

EF Core 9:
InMemoryExpressionTranslatingExpressionVisitor.cs line 887:

        // if object is nullable, add null safeguard before calling the function
        // we special-case Nullable<>.GetValueOrDefault, which doesn't need the safeguard,
        // and Nullable<>.ToString when the object is a nullable value type.
        if (methodCallExpression.Object != null
            && @object!.Type.IsNullableType()
            && methodCallExpression.Method.Name != nameof(Nullable<int>.GetValueOrDefault)
            && (!@object!.Type.IsNullableValueType()
                || methodCallExpression.Method.Name != nameof(Nullable<int>.ToString)))

So the new code adds explicitly a Nullable<>.ToString according to the comment, but this new code throws an exception at runtime because it cannot be called!

(For using an EF Core InMemory database in a unit test, yes I know that's not a good practice, but we inherited this Solution)

The Microsoft.EntityFrameworkCore.SqlServer package does not have this bug, it exhibits the expected behavior.

This seems a regression to me.. It is also interesting to see that the code makes an exception for the int value type, but not for any other value type.

EF Core version: 9.0
Database provider: Microsoft.EntityFrameworkCore.InMemor
Target framework: .NET 0.0)
Operating system: Windows 11 21H2
IDE: Visual Studio 2022 17.12.3

@wilfriedb wilfriedb changed the title EF Core InMemory database throws an argument exception EF Core InMemory database throws an argument exception on Nullable<>.ToString Dec 9, 2024
@maumar
Copy link
Contributor

maumar commented Dec 10, 2024

@wilfriedb can you share the listing of the unit test that is failing (or other form of full repro of this issue)?

I'm trying the following:

    [ConditionalFact]
    public async Task ReproDateOnly()
    {
        using var ctx = new MyContext();
        await ctx.Database.EnsureDeletedAsync();
        await ctx.Database.EnsureCreatedAsync();

        var e1 = new MyEntity { Id = 1, Do = new DateOnly(2000, 1, 1), Ndo = new DateOnly(2024, 2, 2) };
        var e2 = new MyEntity { Id = 2, Do = new DateOnly(2001, 1, 1), Ndo = null };

        ctx.MyEntities.AddRange(e1, e2);
        await ctx.SaveChangesAsync();


        var q = await ctx.MyEntities.Select(x => x.Ndo.ToString()).ToListAsync();
        var q2 = await ctx.MyEntities.Where(x => x.Ndo.ToString() != "foo").ToListAsync();
    }

    public class MyContext : DbContext
    {
        public DbSet<MyEntity> MyEntities { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                    .UseInMemoryDatabase("myDateOnlyDb")
                    .LogTo(Console.WriteLine, LogLevel.Information)
                    .EnableSensitiveDataLogging();
        }
    }

    public class MyEntity
    {
        public int Id { get; set; }
        public DateOnly Do { get; set; }
        public DateOnly? Ndo { get; set; }
    }

and it works fine.

Expression tree we create here is

new QueryingEnumerable<string>(
    queryContext, 
    InMemoryShapedQueryCompilingExpressionVisitor.Table(
        queryContext: queryContext, 
        entityType: EntityType: MyEntity)
        .Select(valueBuffer => new ValueBuffer(new object[]{ ExpressionExtensions.ValueBufferTryReadValue<DateOnly?>(
            valueBuffer: valueBuffer, 
            index: 2, 
            property: Property: MyEntity.Ndo (DateOnly?)).ToString() })) // <- ToString() on Nullable<DateOnly>
        .Select(valueBuffer => new ValueBuffer(new object[]{ ExpressionExtensions.ValueBufferTryReadValue<string>(
            valueBuffer: valueBuffer, 
            index: 0, 
            property: null) })), 
    Func<QueryContext, ValueBuffer, string>, 
    AdHocMiscellaneousQueryInMemoryTest+MyContext, 
    False, 
    True
)

which is has no problems calling ToString method on DateOnly? argument.

@wilfriedb
Copy link
Author

Hello @maumar, I'm in the process of making a minimal repo that will reproduce the issue. There are many, many expressions in this code therefore this process is not going fast . When I've finished this I will post an update here.

@wilfriedb
Copy link
Author

wilfriedb commented Dec 11, 2024

Hello @maumar here is a minimal repo that reproduces the issue: https://github.com/wilfriedb/EFCoreReproduce

In this repo, the unit test will fail with an exception, as mentioned above.
When downgraded to EF Core 8, the test will succeed.

Also, with a minimal change in the file MapperProfile.cs, as indicated in the comments, the test will succeed.

Fun fact, the DateOnly field ValueDate on the PaymentModel class, on which the exception occurs, is not nullable. But when debugging in the file InMemoryExpressionTranslatingExpressionVisitor, the field (in an expression) is promoted (demoted?) to a nullable DateOnly. This is also the case in EF Core 8, but on line 888, in EF Core 9 the if statement is skipped (the condition evaluates to false), but in EF Core 8 the if statement is entered (the condition evaluates to true).

@domagojmedo
Copy link

I have the same issue, here is the 1 file repro https://github.com/domagojmedo/EfCore35299

You can just comment and uncomment line in .csproj

@maumar maumar modified the milestone: Backlog Jan 16, 2025
@domagojmedo
Copy link

Shouldn't this at least be added to breaking changes for EF 9?

@olaj
Copy link

olaj commented Feb 26, 2025

I ran into this, doesn't really understand how it could be fixed / worked around. Do we need to downgrade? Can i ran EF Core 8 with .net 9?

@domagojmedo
Copy link

You can, that's what I did. Using [8.0.11] in test project

@maumar
Copy link
Contributor

maumar commented Mar 5, 2025

regression introduced in #33706
The way we receive the query, ToString method is defined on Int32, but as part of the process, the argument itself becomes nullable (as we are accessing optional entity. We have a code to mitigate that:

|| methodCallExpression.Method.Name != nameof(Nullable<int>.ToString)))

we add a null check and only if the object is not null, we convert the object to non-null, to match the type and call the method. Problem is that we don't do that for Nullable<>.ToString - the way that we check for it is by method name only, so we accidentally also filtered out non-nullable ToString. We should check the declaring type as well

@maumar maumar added this to the 10.0.0 milestone Mar 5, 2025
@maumar maumar added the type-bug label Mar 5, 2025
@maumar
Copy link
Contributor

maumar commented Mar 5, 2025

workaround is to convert the argument to nullable before calling ToString():

                AuthorId = ((int?)x.Author!.Id).ToString()

@domagojmedo
Copy link

Yes, but that means we have to write our EF code differently just because of tests and that's not something I'm very keen on doing. Think we're gonna stay on version 8 and hope it's fixed in 10

maumar added a commit that referenced this issue Mar 11, 2025
…n on Nullable<>.ToString

Problem was that when rewriting the (inmemory) query, sometimes we change the nullability of the expression fragment. (e.g. when accessing a property on an optional entity). We then have a logic to compensate for the change. E.g. when the modified fragment was a caller of a method, we add a null check and only call the method (as non-nullable argument) if the value is not null. This way we can retain the method signature as it was. In 9, we added some exemptions to this logic, e.g. `GetValueOrDefault`, or `Nullable<>.ToString`, which don't need the compensation. Problem was that we were matching the ToString method incorrectly, only looking at the method name, so we would also match non-nullable ToString(). Fix is to look at the declaring type of the ToString method as well to make sure it's nullable<T>, otherwise apply the compensation.

Fixes #35299
@maumar maumar added the closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. label Mar 11, 2025
maumar added a commit that referenced this issue Mar 11, 2025
…n on Nullable<>.ToString

Problem was that when rewriting the (inmemory) query, sometimes we change the nullability of the expression fragment. (e.g. when accessing a property on an optional entity). We then have a logic to compensate for the change. E.g. when the modified fragment was a caller of a method, we add a null check and only call the method (as non-nullable argument) if the value is not null. This way we can retain the method signature as it was. In 9, we added some exemptions to this logic, e.g. `GetValueOrDefault`, or `Nullable<>.ToString`, which don't need the compensation. Problem was that we were matching the ToString method incorrectly, only looking at the method name, so we would also match non-nullable ToString(). Fix is to look at the declaring type of the ToString method as well to make sure it's nullable<T>, otherwise apply the compensation.

Fixes #35299
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-in-memory area-query closed-fixed The issue has been fixed and is/will be included in the release indicated by the issue milestone. customer-reported regression type-bug
Projects
None yet
6 participants