Search code examples
c#.netentity-framework-coreef-core-7.0

Interceptor for ExecuteUpdate crashes when trying to SetPropertyCall with string variable


I'm trying to set the base model that contains edit date and editUserId for all my models through a interceptor when calling ExecuteUpdate. But when I try to set the user id the program crashes with this exception:

valueExpression: AppContext.user))' could not be translated. Additional information: The following 'SetProperty' failed to translate: 'SetProperty(p => p.Author, AppContext.user)'.

This is my code

internal class ExecuteUpdateInterceptor : IQueryExpressionInterceptor
{
    private List<(Type Type, Delegate Calls, Func<IEntityType, bool> Filter)> items = new();

    public ExecuteUpdateInterceptor Add<TSource>(
        Func<Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>>> source,
        Func<IEntityType, bool> filter = null)
    {
        items.Add((typeof(TSource), source, filter));
        return this;
    }

    Expression IQueryExpressionInterceptor.QueryCompilationStarting(
        Expression queryExpression, QueryExpressionEventData eventData)
    {
        try
        {
            if (queryExpression is MethodCallExpression call &&
                call.Method.DeclaringType == typeof(RelationalQueryableExtensions) &&
                (call.Method.Name == nameof(RelationalQueryableExtensions.ExecuteUpdate) ||
                call.Method.Name == nameof(RelationalQueryableExtensions.ExecuteUpdateAsync)))
            {
                var setPropertyCalls = (LambdaExpression)((UnaryExpression)call.Arguments[1]).Operand;
                var body = setPropertyCalls.Body;
                var parameter = setPropertyCalls.Parameters[0];
                var targetType = eventData.Context?.Model.FindEntityType(parameter.Type.GetGenericArguments()[0]);
                if (targetType != null)
                {
                    foreach (var item in items)
                    {
                        if (!item.Type.IsAssignableFrom(targetType.ClrType))
                            continue;
                        if (item.Filter != null && !item.Filter(targetType))
                            continue;
                        var calls = (LambdaExpression)item.Calls.Method.GetGenericMethodDefinition()
                            .MakeGenericMethod(targetType.ClrType)
                            .Invoke(null, null);
                        body = calls.Body.ReplaceParameter(calls.Parameters[0], body);
                    }
                    if (body != setPropertyCalls.Body)
                    { 
                        //body
                        return call.Update(call.Object, new[] { call.Arguments[0], Expression.Lambda(body, parameter) }); 
                    }
                }
            }
            return queryExpression;
        }
        catch (Exception ex)
        {
            throw;
        }
    }
}

and

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.AddInterceptors(new ExecuteUpdateInterceptor()
                        .Add(SetBaseProperty<BassyBase>));
    }
    public static Guid? user = new Guid("D4DB8C0E-E21B-4456-8974-22B2FFE0F2C1");

    private static Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> SetBaseProperty<TSource>()
    where TSource : BassyBase
    {
        Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> props = s => s
            .SetProperty(p => p.ChangeDate, DateTimeOffset.UtcNow)
            .SetProperty(p => p.Author, user);

        return props;
    }

if I try to set it inline with SetProperty(p => p.Author, Guid.NewGuid() or new Guid("XXX") ) it works.

But when I try to get the string or the guid from a variable it seems to try to send a reference of the variable instead of the value which crashes the query.


Solution

  • Quite annoying EF Core bug/limitation. For a long time, we (the people writing extensions/workarounds for expression tree manipulation) asked for extension point in query translation pipeline where we can receive the original expression tree and do our preprocessing/transformation before EF Core. We eventually got it finally, but as it turns out, in a typical EF Core way, after some EF Core preprocessing, and specifically parameterization of the query.

    The last is very important detail, since it limits what you can do inside the interceptor. Normally expressions that use captured variables or static properties/fields/method accessors like this (note we are inside expression tree, so there are no real calls)

    .SetProperty(p => p.Author, user)
    

    are turned into (replaced with) parameters. But since the interceptor is called after parameterization, they are not converted to parameters, and later on are reported as unknown/unsupported expressions are any custom method call inside expression tree.

    Shorty, the way it is now, you can't introduce non constant expressions in the resulting expression tree. And to make the things harder, C# does not provide a syntax for creating constant expressions from variables (i.e. other than using const fields).

    So, first it is good to go and report this as a bug in the EF Core GitHub issue tracker and request a fix (and people go and vote for it). Second, the only workaround I see for using interceptor like this is to convert the variable to a constant. Which can be done with manual expression tree building, or as usual, by creating prototype expression with additional parameter "markers", and then replacing them with constant values.

    With your example, it could be something like this:

    // Expression with user being a parameter
    Expression<Func<SetPropertyCalls<TSource>, string, SetPropertyCalls<TSource>>> propsP = static (s, user) => s
        .SetProperty(p => p.ChangeDate, DateTimeOffset.UtcNow)
        .SetProperty(p => p.Author, user);
    
    // The actual expression with user value being a constant
    var props = Expression.Lambda<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>>(
        propsP.Body.ReplaceParameter(propsP.Parameters[1], Expression.Constant(user)),
        propsP.Parameters[0]);
    
    return props;