Как обновить теневые свойства с помощью метода Entity Framework Core ExecuteUpdate?

При сохранении изменений в базе данных мы хотим автоматически обновлять наши теневые свойства (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);
    }
}

Документально подтверждено, что ExecuteUpdate не используйте ChangeTracker. Попробуйте _.SetProperty(invoice => EF.Property<DateTime>(invoice, nameof(BaseIdentifierEntity.UpdatedOnOn), invoice => DateTime.Now)

Svyatoslav Danyliv 15.02.2023 16:59

@SvyatoslavDanyliv, в этом весь смысл моего вопроса. Мне нужен общий метод для обновления свойств тени. В вашем примере мне нужно установить ModifiedOn везде, где я использую ExecuteUpdateAsync. Я хочу избежать этого, потому что цель теневых свойств состоит в том, что вам не нужно устанавливать их везде явно.

Boomit 15.02.2023 17:08

Я понял. Если сегодня не будет ответов, завтра подготовлю решение.

Svyatoslav Danyliv 15.02.2023 17:10
Ускорьте разработку веб-приложений Laravel с помощью этих бесплатных стартовых наборов
Ускорьте разработку веб-приложений Laravel с помощью этих бесплатных стартовых наборов
Laravel - это мощный PHP-фреймворк, используемый для создания масштабируемых и надежных веб-приложений. Одним из преимуществ Laravel является его...
Что такое двойные вопросительные знаки (??) в JavaScript?
Что такое двойные вопросительные знаки (??) в JavaScript?
Как безопасно обрабатывать неопределенные и нулевые значения в коде с помощью Nullish Coalescing
Создание ресурсов API Laravel: Советы по производительности и масштабируемости
Создание ресурсов API Laravel: Советы по производительности и масштабируемости
Создание API-ресурса Laravel может быть непростой задачей. Она требует глубокого понимания возможностей Laravel и лучших практик, чтобы обеспечить...
Как сделать компонент справочного центра с помощью TailwindCSS
Как сделать компонент справочного центра с помощью TailwindCSS
Справочный центр - это веб-сайт, где клиенты могут найти ответы на свои вопросы и решения своих проблем. Созданный для решения многих распространенных...
Асинхронная передача данных с помощью sendBeacon в JavaScript
Асинхронная передача данных с помощью sendBeacon в JavaScript
В современных веб-приложениях отправка данных из JavaScript на стороне клиента на сервер является распространенной задачей. Одним из популярных...
Как подобрать выигрышные акции с помощью анализа и визуализации на Python
Как подобрать выигрышные акции с помощью анализа и визуализации на Python
Отказ от ответственности: Эта статья предназначена только для демонстрации и не должна использоваться в качестве инвестиционного совета.
3
3
65
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Следующее расширение обновляет теневое свойство с другими полями:

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);
    }
}
Ответ принят как подходящий

На самом деле тут два-три вопроса, так что давайте разбираться с ними отдельно.

  1. Как обновить теневые свойства с помощью метода Entity Framework Core ExecuteUpdate?

Доступ к теневым свойствам внутри любого дерева выражения запроса EF Core осуществляется с помощью метода EF.Property, который представляет собой универсальное выражение доступа к свойствам EF Core и работает как для теневых, так и для обычных свойств.

Итак, если ваше ModifiedOn было теневым свойством (а оно им не является) типа DateTime, его можно обновить следующим образом:

query.ExecuteUpdateAsync(s => s
    .SetProperty(p => EF.Property<DateTime>("ModifiedOn"), DateTime.UtcNow)
    ...);
  1. Как комбинировать лямбда-выражения?

Это было освещено многими ответами на 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);
  1. Теперь, получив ответы на два предыдущих вопроса, следующим будет: можно ли сделать это лучше в EF Core вместо использования настраиваемого метода расширения? В идеале по аналогии с подходом отслеживания изменений (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, поэтому думал, что это невозможно, но это, конечно, правильный путь. Большое спасибо!

Boomit 16.02.2023 16:12

Другие вопросы по теме