При сохранении изменений в базе данных мы хотим автоматически обновлять наши теневые свойства (CreatedOn и ModifiedOn). Это можно сделать с помощью переопределения метода SaveChangesAsync в классе DbContext.
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);
}
Теперь мы хотим использовать метод кода EF ExecuteUpdateAsync для массового обновления записей, но эти изменения не обнаруживаются средством отслеживания изменений.
Например.
await context.Invoices
.Where(_ => _.Status == InvoiceStatusEnum.Draft)
.ExecuteUpdateAsync(_ => _.SetProperty(invoice => invoice.Status, InvoiceStatusEnum.Approved), cancellationToken);
Одним из возможных решений, о котором мы думаем, является метод ExecuteUpdateWithShadowPropertiesAsync, но нам не удается объединить два выражения в одно.
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);
}
}
@SvyatoslavDanyliv, в этом весь смысл моего вопроса. Мне нужен общий метод для обновления свойств тени. В вашем примере мне нужно установить ModifiedOn везде, где я использую ExecuteUpdateAsync. Я хочу избежать этого, потому что цель теневых свойств состоит в том, что вам не нужно устанавливать их везде явно.
Я понял. Если сегодня не будет ответов, завтра подготовлю решение.
Следующее расширение обновляет теневое свойство с другими полями:
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);
}
}
На самом деле тут два-три вопроса, так что давайте разбираться с ними отдельно.
Доступ к теневым свойствам внутри любого дерева выражения запроса EF Core осуществляется с помощью метода EF.Property, который представляет собой универсальное выражение доступа к свойствам EF Core и работает как для теневых, так и для обычных свойств.
Итак, если ваше ModifiedOn было теневым свойством (а оно им не является) типа DateTime, его можно обновить следующим образом:
query.ExecuteUpdateAsync(s => s
.SetProperty(p => EF.Property<DateTime>("ModifiedOn"), DateTime.UtcNow)
...);
Это было освещено многими ответами на SO или в Интернете. Но в основном вам нужно эмулировать «вызов» одного из выражений, передавая другое в качестве аргумента. Это достигается либо с помощью Expression.Invoke, что не всегда поддерживается трансляторами запросов (включая EF Core), либо (что всегда работает) путем замены параметра «вызванного» лямбда-выражения телом другого лямбда-выражения.
Последнее достигается с помощью пользовательского ExpressionVisitor. Вы можете найти много реализаций, EF Core также предоставляет свой собственный, называемый ParameterReplacingVisitor, но я использую свой собственный небольшой вспомогательный класс выражений, который является общим и не имеет EF Core или других сторонних зависимостей. Это довольно просто:
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;
}
}
С этим помощником метод, который вы ищете, будет:
// 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);
Вы можете пойти дальше и добавить вспомогательный метод быстрого доступа, специфичный для 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);
И тогда рассматриваемый общий метод будет просто:
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
).И да. В EF Core 7.0 вместе с пакетными обновлениями появилась долгожданная и очень удобная функция под названием Перехват для изменения дерева выражений LINQ (к сожалению, пока не задокументирована). Он позволяет перехватывать и изменять дерево выражений запроса LINQ перед EF Core. В этом случае его можно использовать для добавления дополнительных свойств обновления в запрос ExecuteUpdate.
Чтобы использовать его, мы сначала определяем класс перехватчика
#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;
}
}
Это требует немного больше знаний о выражениях, поэтому вы можете просто использовать его как есть. По сути, он перехватывает ExecuteUpdate «вызовы» и добавляет дополнительные SetProperty «вызовы» на основе статически настроенных правил и фильтров.
Осталось только создать, настроить и добавить перехватчик внутри вашего переопределения OnConfigure:
optionsBuilder.AddInterceptors(new ExecuteUpdateInterceptor()
//.Add(...)
//.Add(...)
);
Конфигурация основана на делегатах, с единственным ограничением/требованием, чтобы SetPropertyCalls универсальный Func был настоящим универсальным методом, а не анонимным делегатом (я не нашел способа сделать его простым в использовании и в то же время анонимным).
Итак, вот некоторые варианты использования:
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);
Примечание. Функция SetPropertyCalls должна быть универсальной, чтобы ее можно было привязать к фактическому типу источника из запроса.
Кроме того, я не упоминал об этом явно до сих пор, но с последним подходом вы просто используете стандартные методы ExecuteUpdate или ExecuteUpdateAsync, а перехватчик добавляет настроенные дополнительные SetProperty выражения.
Я не знал о перехватчиках в EF Core 7.0, поэтому думал, что это невозможно, но это, конечно, правильный путь. Большое спасибо!
Документально подтверждено, что ExecuteUpdate не используйте ChangeTracker. Попробуйте _.SetProperty(invoice => EF.Property<DateTime>(invoice, nameof(BaseIdentifierEntity.UpdatedOnOn), invoice => DateTime.Now)