dotnet / efcore

EF Core is a modern object-database mapper for .NET. It supports LINQ queries, change tracking, updates, and schema migrations.
https://docs.microsoft.com/ef/
MIT License
13.64k stars 3.15k forks source link

Incompatibility with Dbcontext, DbFunction and Interface #33025

Open chrisdreams13 opened 7 months ago

chrisdreams13 commented 7 months ago

DbFunction don't get the mapping function when an interface is used

There's a strange behavior when an interface is used instead of the direct object of dbcontext when a DbFunciton is called. In the code bellow, I create a function in sql (Test) and added it to my context using DbFunctionAttribute.

If MyDbContext is used to call the Test function works ok. The problem is when the IMyDbContext interface is required to the dependency injection, the Test function throws the NotSupportedException like the mapping doesn't exist.

I tried to cast the interface reference to the base class and works ok, but I believe it's not the desired behavior using interfaces. Also, I tried to add virtual to Test function in MyDbContext, but didn't works with the interface.

Code

CREATE FUNCTION dbo.Test(@i int) RETURNS int AS
begin
  return @i;
end
public interface IMyDbContext {
    int Test(int i);
}
public class MyDbContext(DbContextOptions<MyDbContext> dbContextOptions) : DbContext(dbContextOptions), IMyDbContext {
    ...
    [DbFunction("Test", "dbo", IsBuiltIn = false)]
    public int Test(int i) => throw new NotSupportedException();
}
// Added both to my dependency injection
var builder = WebApplication.CreateBuilder(args);
builder
    .AddDbContext<IMyDbContext, MyDbContext>(ServiceLifetime.Transient)
    .AddDbContext<MyDbContext>(ServiceLifetime.Transient);
public class MyController(IMyDbContext iContext, MyDbContext context): ControllerBase
{
    public IActionResult TestOk() => Ok(
        context.Users.Select(it => context.Test(it.Id))); // using concrete class

    public IActionResult TestWrong() => Ok(
        iContext .Users.Select(it => iContext .Test(it.Id))); // using interface

    public virtual IActionResult TestCastOk()
    {
        var ctx = iContext as MyDbContext;                    // using interface, casted to concrete class
        return Ok(ctx!.Users.Select(it => ctx.Tomorrow(it.Id)));
    }
}

Stack traces

[12:43:18 DBG] Compiling query expression: 
'DbSet<User>()
    .Select(it => __P_0.Test(it.Id))'
[12:43:18 DBG] Generated query execution expression: 
'queryContext => new SingleQueryingEnumerable<int>(
    (RelationalQueryContext)queryContext, 
    RelationalCommandCache.QueryExpression(
        Client Projections:
            0 -> 0
        SELECT u.Id
        FROM Users.Users AS u), 
    null, 
    Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, int>, 
    MyDbContext, 
    False, 
    True, 
    True
)'
[12:43:18 DBG] Creating DbConnection.
[12:43:18 DBG] Created DbConnection. (2ms).
[12:43:18 DBG] Opening connection to database '' on server ''.
[12:43:18 DBG] Opened connection to database '' on server ''.
[12:43:18 DBG] Creating DbCommand for 'ExecuteReader'.
[12:43:18 DBG] Created DbCommand for 'ExecuteReader' (1ms).
[12:43:18 DBG] Initialized DbCommand for 'ExecuteReader' (3ms).
[12:43:18 DBG] Executing DbCommand [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT [u].[Id]
FROM [Users].[Users] AS [u]
[12:43:18 INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT [u].[Id]
FROM [Users].[Users] AS [u]
[12:43:18 ERR] 
An exception occurred while iterating over the results of a query for context type 'MyDbContext'.
System.NotSupportedException.
   at MyDbContext.Test(Int32 i) in MyDbContext.Accounting
   at lambda_method39(Closure, QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator)
   at Microsoft.EntityFrameworkCore.Query.Internal.SingleQueryingEnumerable`1.Enumerator.MoveNext()
[12:43:18 DBG] Closing data reader to '' on server ''.
[12:43:18 DBG] A data reader for '' on server '' is being disposed after spending 113ms reading results.

Include provider and version information

EF Core version: Microsoft.EntityFrameworkCore, 8.0.0 Database provider: Microsoft.EntityFrameworkCore.SqlServer, 8.0.0 Target framework: net8.0 Operating system: Windows 10 IDE: Microsoft Visual Studio Professional 2022 (64-bit) 17.8.3

ajcvickers commented 6 months ago

Full repro below:

using (var context = new SomeDbContext())
{
    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();
    context.Add(new Foo());
    context.SaveChanges();

    context.Database.ExecuteSqlRaw(
        """
        CREATE FUNCTION dbo.Test(@i int) RETURNS int AS
        begin
          return @i;
        end
        """
    );
}

using (var context = new SomeDbContext())
{
    Console.WriteLine(context.Set<Foo>().Select(e => context.Test(e.Id)).FirstOrDefault());
}

using (var context = new SomeDbContext())
{
    WithInterface(context, context);
}

void WithInterface(SomeDbContext context1, IMyDbContext context2)
{
    Console.WriteLine(context1.Foos.Select(e => context2.Test(e.Id)).FirstOrDefault());
}

public class SomeDbContext : DbContext, IMyDbContext
{
    public DbSet<Foo> Foos => Set<Foo>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(@"Data Source=localhost;Database=One;Integrated Security=True;Trust Server Certificate=True")
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    [DbFunction("Test", "dbo", IsBuiltIn = false)]
    public int Test(int i) => throw new NotSupportedException();
}

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

public interface IMyDbContext
{
    int Test(int i);
}
chris-pie commented 5 months ago

This also happens when inheriting SomeDbContext class and overriding parent's virtual method with the annotation.

See the gist for fuller repro:

https://gist.github.com/chris-pie/5c7a2a2215947b1a55ac9f7d36829c8b

chris-pie commented 5 months ago

This seems to be a result of a quirk? (I'm not informed enough to say it's a bug) in the compiler itself. The following line Expression<Func<string>> selector = () => context.Test(1);

Produces following IL

      IL_00c0: ldloc.1      // 'CS$<>8__locals0'
      IL_00c1: ldtoken      Program/'<>c__DisplayClass0_0'
      IL_00c6: call         class [System.Runtime]System.Type [System.Runtime]System.Type::GetTypeFromHandle(valuetype [System.Runtime]System.RuntimeTypeHandle)
      IL_00cb: call         class [System.Linq.Expressions]System.Linq.Expressions.ConstantExpression [System.Linq.Expressions]System.Linq.Expressions.Expression::Constant(object, class [System.Runtime]System.Type)
      IL_00d0: ldtoken      field class InheritingDbContext Program/'<>c__DisplayClass0_0'::context
      IL_00d5: call         class [System.Runtime]System.Reflection.FieldInfo [System.Runtime]System.Reflection.FieldInfo::GetFieldFromHandle(valuetype [System.Runtime]System.RuntimeFieldHandle)
      IL_00da: call         class [System.Linq.Expressions]System.Linq.Expressions.MemberExpression [System.Linq.Expressions]System.Linq.Expressions.Expression::Field(class [System.Linq.Expressions]System.Linq.Expressions.Expression, class [System.Runtime]System.Reflection.FieldInfo)
      IL_00df: ldtoken      method instance string SomeDbContext::Test(int32)
      IL_00e4: call         class [System.Runtime]System.Reflection.MethodBase [System.Runtime]System.Reflection.MethodBase::GetMethodFromHandle(valuetype [System.Runtime]System.RuntimeMethodHandle)
      IL_00e9: castclass    [System.Runtime]System.Reflection.MethodInfo

And similarily for using the object as interface:

  IL_0035: ldloc.0      // 'CS$<>8__locals0'
    IL_0036: ldtoken      Program/'<>c__DisplayClass0_4'
    IL_003b: call         class [System.Runtime]System.Type [System.Runtime]System.Type::GetTypeFromHandle(valuetype [System.Runtime]System.RuntimeTypeHandle)
    IL_0040: call         class [System.Linq.Expressions]System.Linq.Expressions.ConstantExpression [System.Linq.Expressions]System.Linq.Expressions.Expression::Constant(object, class [System.Runtime]System.Type)
    IL_0045: ldtoken      field class IMyDbContext Program/'<>c__DisplayClass0_4'::contextInterface
    IL_004a: call         class [System.Runtime]System.Reflection.FieldInfo [System.Runtime]System.Reflection.FieldInfo::GetFieldFromHandle(valuetype [System.Runtime]System.RuntimeFieldHandle)
    IL_004f: call         class [System.Linq.Expressions]System.Linq.Expressions.MemberExpression [System.Linq.Expressions]System.Linq.Expressions.Expression::Field(class [System.Linq.Expressions]System.Linq.Expressions.Expression, class [System.Runtime]System.Reflection.FieldInfo)
    IL_0054: ldtoken      method instance string IMyDbContext::Test(int32)
    IL_0059: call         class [System.Runtime]System.Reflection.MethodBase [System.Runtime]System.Reflection.MethodBase::GetMethodFromHandle(valuetype [System.Runtime]System.RuntimeMethodHandle)
    IL_005e: castclass    [System.Runtime]System.Reflection.MethodInfo

See that it is not getting methodInfo of the object's actual class, but either from the interface or the parent class in case of virtual methods. What this means is that by the time any EFCore function is called we already can't tell which particular method was actually called and so we can't reliably map it to a DB function. I've implemented a workaround for this although it means manually fixing the parameter type in the Funcletizer (since by the time we get to translation original type is gone from the expression tree). This seems to work and doesn't break any existing tests but is probably not the ideal approach. Maybe instead we could replace the method instead of the type in the funcletizer using the same logic, or pass the original object's type to query compilation context from the funcletizer. Also might be worth pinging runtime/compiler teams with this,