On saving changes to the database, we want to update our shadow properties (CreatedOn & ModifiedOn) automatically. This can be done by using overriding SaveChangesAsync method in the DbContext class.
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.DetectChanges();
var timestamp = systemClock.UtcNow.DateTime;
foreach (var entry in ChangeTracker.Entries()
.Where(e => e.Entity is BaseIdentifierEntity)
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified))
{
if (entry.State == EntityState.Added)
{
entry.Property(nameof(BaseIdentifierEntity.CreatedOn)).CurrentValue = timestamp;
}
if (entry.State == EntityState.Modified)
{
entry.Property(nameof(BaseIdentifierEntity.ModifiedOn)).CurrentValue = timestamp;
}
};
return base.SaveChangesAsync(cancellationToken);
}
Now we want to use the ExecuteUpdateAsync EF code method to update records in bulk but those changes are not detected by the change tracker.
Eg.
await context.Invoices
.Where(_ => _.Status == InvoiceStatusEnum.Draft)
.ExecuteUpdateAsync(_ => _.SetProperty(invoice => invoice.Status, InvoiceStatusEnum.Approved), cancellationToken);
One possible solution we're thinking about, is having a ExecuteUpdateWithShadowPropertiesAsync method but we don't succeed to merge the 2 expressions into one.
public static class EntityFrameworkExtensions
{
public static Task<int> ExecuteUpdateWithShadowPropertiesAsync<TSource>(this IQueryable<TSource> source, Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setPropertyCalls, CancellationToken cancellationToken = default) where TSource : BaseIdentifierEntity
{
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setShadowPropertyCalls = _ => _.SetProperty(p => p.ModifiedOn, DateTime.UtcNow);
// TODO: A method to combine both expressions into one expression
var mergedPropertyCalls = Merge(setPropertyCalls, setShadowPropertyCalls);
return source.ExecuteUpdateAsync(mergedPropertyCalls, cancellationToken: cancellationToken);
}
}
Actually there are two or three questions here, so let handle them separately.
Shadow properties inside any EF Core query expression tree are accessed through EF.Property method, which is EF Core generic property accessor expression and works for both shadow and regular properties.
So if your ModifiedOn was a shadow property (it isn't) of type DateTime, it can be updated as follows:
query.ExecuteUpdateAsync(s => s
.SetProperty(p => EF.Property<DateTime>("ModifiedOn"), DateTime.UtcNow)
...);
This has been covered by many answers on SO, or over internet. But basically you need to emulate "call" to one of the expressions passing the other as argument. This is achieved with either Expression.Invoke which is not always supported by query translators (including EF Core), or (which always works) by replacing the parameter of the "called" lambda expression with the body of the other lambda expression.
The later is achieved with custom ExpressionVisitor. You can find many implementations, EF Core also provides its own called ParameterReplacingVisitor, but I'm using my own little expression helper class, which is general and have no EF Core or other 3rd party dependencies. It is quite simple:
namespace System.Linq.Expressions;
public static class ExpressionUtils
{
public static Expression ReplaceBodyParameter<T, TResult>(this Expression<Func<T, TResult>> source, Expression value)
=> source.Body.ReplaceParameter(source.Parameters[0], value);
public static Expression ReplaceParameter(this Expression source, ParameterExpression target, Expression replacement)
=> new ParameterReplacer(target, replacement).Visit(source);
class ParameterReplacer : ExpressionVisitor
{
readonly ParameterExpression target;
readonly Expression replacement;
public ParameterReplacer(ParameterExpression target, Expression replacement)
=> (this.target, this.replacement) = (target, replacement);
protected override Expression VisitParameter(ParameterExpression node)
=> node == target ? replacement : node;
}
}
With that helper, the method you are looking for would be:
// TODO: A method to combine both expressions into one expression
var mergedPropertyCalls = Expression.Lambda<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>>(
setShadowPropertyCalls.ReplaceBodyParameter(setPropertyCalls.Body),
setPropertyCalls.Parameters);
You can go further and add a shortcut helper method specific for SetPropertyCalls:
public static Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> Append<TSource>(
this Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> target,
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> source)
where TSource : class
=> Expression.Lambda<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>>(
source.ReplaceBodyParameter(target.Body), target.Parameters);
and then the generic method in question would be simply:
public static Task<int> ExecuteUpdateWithShadowPropertiesAsync<TSource>(
this IQueryable<TSource> source,
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setPropertyCalls,
CancellationToken cancellationToken = default)
where TSource : BaseIdentifierEntity
=> source.ExecuteUpdateAsync(setPropertyCalls
.Append(s => s.SetProperty(p => p.ModifiedOn, DateTime.Now)),
cancellationToken);
SaveChanges) approach.And the answer is yes. EF Core 7.0 along with batch updates introduced a long asked and very handy feature called Interception to modify the LINQ expression tree (unfortunately not documented yet). It allows you to intercept and modify LINQ query expression tree before EF Core. In this case, it could be used to add more update properties to ExecuteUpdate query.
In order to utilize it, we first define interceptor class
#nullable disable
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Query;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
namespace Microsoft.EntityFrameworkCore;
internal class ExecuteUpdateInterceptor : IQueryExpressionInterceptor
{
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)
{
if (queryExpression is MethodCallExpression call &&
call.Method.DeclaringType == typeof(RelationalQueryableExtensions) &&
call.Method.Name == nameof(RelationalQueryableExtensions.ExecuteUpdate))
{
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)
return call.Update(call.Object, new[] { call.Arguments[0], Expression.Lambda(body, parameter) });
}
}
return queryExpression;
}
}
This requires a bit more knowledge of expressions, so you can just use it as is. Basically what it does is intercepting the ExecuteUpdate "calls" and appending additional SetProperty "calls" based on statically configured rules and filters.
The only remaining is to create, configure and add the interceptor inside your OnConfigure override:
optionsBuilder.AddInterceptors(new ExecuteUpdateInterceptor()
//.Add(...)
//.Add(...)
);
The configuration is based on delegates, with only limitation/requirement the SetPropertyCalls generic Func to be a real generic method and not anonymous delegate (I haven't found a way to make it easy for use and at the same time being anonymous).
So here are some usages:
optionsBuilder.AddInterceptors(new ExecuteUpdateInterceptor()
.Add(SetModifiedOn<BaseIdentifierEntity>)
);
static Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> SetModifiedOn<TSource>()
where TSource : BaseIdentifierEntity
=> s => s.SetProperty(p => p.ModifiedOn, DateTime.UtcNow);
const string ModifiedOn = nameof(ModifiedOn);
optionsBuilder.AddInterceptors(new ExecuteUpdateInterceptor()
.Add(SetModifiedOn<object>, t => t.FindProperty(ModifiedOn) is { } p && p.ClrType == typeof(DateTime))
);
static Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> SetModifiedOn<TSource>()
where TSource : class
=> s => s.SetProperty(p => EF.Property<DateTime>(p,ModifiedOn), DateTime.UtcNow);
Note: The SetPropertyCalls func must be generic, to allow binding it to the actual source type from the query.
Also, I haven't mentioned it explicitly till now, but with the last approach, you just use a standard ExecuteUpdate or ExecuteUpdateAsync methods, and the interceptor adds the cofigured additional SetProperty expressions.
The following extension updates shadow property with other fields:
public static class EntityFrameworkExtensions
{
public static Task<int> ExecuteUpdateWithShadowPropertiesAsync<TSource>(this IQueryable<TSource> source,
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setPropertyCalls,
CancellationToken cancellationToken = default)
where TSource : class
{
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setShadowPropertyCalls =
x => x.SetProperty(p => EF.Property<DateTime>(p, "ModifiedOn"), p => DateTime.UtcNow);
var mergedPropertyCalls = Merge(setPropertyCalls, setShadowPropertyCalls);
return source.ExecuteUpdateAsync(mergedPropertyCalls, cancellationToken: cancellationToken);
}
static Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> Merge<TSource>(
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> setPropertyCalls,
Expression<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>> additional)
{
var newBody = ReplacingExpressionVisitor.Replace(additional.Parameters[0], setPropertyCalls.Body, additional.Body);
return Expression.Lambda<Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>>(newBody,
setPropertyCalls.Parameters);
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With