Поделиться через


Глобальные фильтры запросов

Глобальные фильтры запросов позволяют присоединять фильтр к типу сущности и применять этот фильтр при выполнении запроса к этому типу сущности; они считаются дополнительным оператором LINQ Where , который добавляется всякий раз, когда тип сущности запрашивается. Такие фильтры полезны в различных случаях.

Подсказка

Вы можете скачать используемый в этой статье пример из репозитория GitHub.

Базовый пример— обратимое удаление

В некоторых сценариях вместо удаления строки из базы данных предпочтительнее вместо этого задать IsDeleted флаг, чтобы пометить строку как удаленную. Этот шаблон называется обратимым удалением. Мягкое удаление позволяет восстанавливать строки при необходимости или сохранить журнал аудита, в котором удаленные строки остаются доступными. Глобальные фильтры запросов можно использовать по умолчанию для фильтрации мягко удаленных строк, при этом предоставляя возможность доступа к ним в определенных контекстах путем отключения фильтра для конкретного запроса.

Чтобы включить мягкое удаление, давайте добавим IsDeleted свойство к типу блога:

public class Blog
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }

    public string Name { get; set; }
}

Теперь мы настроили глобальный фильтр запросов с помощью HasQueryFilter API в OnModelCreating:

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);

Теперь мы можем запрашивать сущности Blog как обычно. Настроенный фильтр гарантирует, что все запросы будут отфильтровывать все экземпляры, где IsDeleted имеет значение true.

Обратите внимание, что на этом этапе необходимо вручную установить IsDeleted для обратимого удаления сущности. Для более комплексного решения можно переопределить метод типа SaveChangesAsync контекста, чтобы добавить логику, которая проходит по всем сущностям, которые пользователь удалил, и изменяет их, помечая как изменённые, присвоив свойству IsDeleted значение true.

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    ChangeTracker.DetectChanges();

    foreach (var item in ChangeTracker.Entries<Blog>().Where(e => e.State == EntityState.Deleted))
    {
        item.State = EntityState.Modified;
        item.CurrentValues["IsDeleted"] = true;
    }

    return await base.SaveChangesAsync(cancellationToken);
}

Это позволяет использовать API EF, которые удаляют экземпляр сущности как обычно, и вместо этого они будут мягко удаляться.

Использование контекстных данных — многотенантность

Другой основной сценарий для глобальных фильтров запросов — многотенантность, в которой приложение хранит данные, принадлежащие разным пользователям в одной таблице. В таких случаях обычно существует столбец идентификатора клиента , который связывает строку с конкретным клиентом, а глобальные фильтры запросов можно использовать для автоматического фильтрации строк текущего клиента. Это обеспечивает надежную изоляцию арендатора для запросов по умолчанию, исключая необходимость думать о фильтрации для арендатора в каждом запросе.

В отличие от обратимого удаления, для многотенантности требуется знание текущего идентификатора клиента; Обычно это значение определяется, например, когда пользователь проходит проверку подлинности через Интернет. Для целей EF идентификатор клиента должен быть доступен в экземпляре контекста, чтобы глобальный фильтр запросов мог ссылаться на него и использовать при выполнении запросов. Давайте примем tenantId параметр в конструкторе типа контекста и используем это в нашем фильтре:

public class MultitenancyContext(string tenantId) : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);
    }
}

Это приводит к тому, что любой пользователь создает контекст для указания связанного идентификатора клиента и гарантирует, что только Blog сущности с этим идентификатором возвращаются из запросов по умолчанию.

Замечание

В этом примере показаны только основные понятия многотенантности, необходимые для демонстрации глобальных фильтров запросов. Дополнительные сведения о мультитенантности и EF см. в разделе мультитенантность в приложениях EF Core.

Использование нескольких фильтров запросов

Вызов HasQueryFilter с помощью простого фильтра перезаписывает любой предыдущий фильтр, поэтому несколько фильтров нельзя определить в одном типе сущности таким образом:

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);
// The following overwrites the previous query filter:
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);

Замечание

Эта функция представлена в EF Core 10.0 (в предварительной версии).

Чтобы определить несколько фильтров запросов в одном типе сущности, они должны быть названы:

modelBuilder.Entity<Blog>()
    .HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
    .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);

Это позволяет управлять каждым фильтром отдельно, включая выборочное отключение одного, но не другого.

Отключение фильтров

Фильтры могут быть отключены для отдельных запросов LINQ с помощью IgnoreQueryFilters оператора:

var allBlogs = await context.Blogs.IgnoreQueryFilters().ToListAsync();

Если настроено несколько именованных фильтров, это отключает все из них. Чтобы выборочно отключить определенные фильтры (начиная с EF 10), передайте список имен фильтров, которые необходимо отключить:

var allBlogs = await context.Blogs.IgnoreQueryFilters(["SoftDeletionFilter"]).ToListAsync();

Фильтры запросов и обязательные переходы

Осторожность

Использование необходимой навигации для доступа к сущности, которая имеет глобальный фильтр запросов, может привести к непредвиденным результатам.

Обязательные навигации в EF подразумевают, что связанная сущность всегда присутствует. Так как внутренние соединения могут использоваться для получения связанных сущностей, если требуемая связанная сущность отфильтровывается фильтром запросов, родительская сущность также может быть отфильтрована. Это может привести к неожиданному получению меньше элементов, чем ожидалось.

Чтобы проиллюстрировать проблему, можно использовать сущности Blog и Post и настроить их следующим образом:

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

Модель может быть заполнена следующими данными:

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/fish",
        Posts =
        [
            new() { Title = "Fish care 101" },
            new() { Title = "Caring for tropical fish" },
            new() { Title = "Types of ornamental fish" }
        ]
    });

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/cats",
        Posts =
        [
            new() { Title = "Cat care 101" },
            new() { Title = "Caring for tropical cats" },
            new() { Title = "Types of ornamental cats" }
        ]
    });

Проблема может наблюдаться при выполнении следующих двух запросов:

var allPosts = await db.Posts.ToListAsync();
var allPostsWithBlogsIncluded = await db.Posts.Include(p => p.Blog).ToListAsync();

При приведенной выше настройке первый запрос возвращает все 6 Post экземпляров, но второй запрос возвращает только 3. Это несоответствие возникает, так как Include метод во втором запросе загружает связанные Blog сущности. Так как навигация между Blog и Post требуется, EF Core используется INNER JOIN при создании запроса:

SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[IsDeleted], [p].[Title], [t].[BlogId], [t].[Name], [t].[Url]
FROM [Posts] AS [p]
INNER JOIN (
    SELECT [b].[BlogId], [b].[Name], [b].[Url]
    FROM [Blogs] AS [b]
    WHERE [b].[Url] LIKE N'%fish%'
) AS [t] ON [p].[BlogId] = [t].[BlogId]

Использование INNER JOIN фильтра исключает все Post строки, связанные с Blog строками, которые были отфильтрованы фильтром запросов. Эта проблема может быть решена, настроив навигацию как необязательную, вместо обязательной, что приводит к тому, что EF создает LEFT JOIN вместо INNER JOIN:

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false);
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

Альтернативный подход заключается в указании согласованных фильтров как для Blog, так и для Post типов сущностей. После применения соответствующих фильтров к Blog и Post, строки Post, которые могут оказаться в непредвиденном состоянии, удаляются, и оба запроса возвращают 3 результата.

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));
modelBuilder.Entity<Post>().HasQueryFilter(p => p.Blog.Url.Contains("fish"));

Фильтры запросов и IEntityTypeConfiguration

Если фильтру запросов требуется доступ к идентификатору клиента или аналогичной контекстной информации, IEntityTypeConfiguration<TEntity> может вызвать дополнительное осложнение, поскольку, в отличие от OnModelCreating, экземпляр вашего типа контекста не доступен для использования в фильтре запросов. В качестве обходного решения добавьте фиктивный контекст в тип конфигурации и используйте его как ссылку, как указано ниже.

private sealed class CustomerEntityConfiguration : IEntityTypeConfiguration<Customer>
{
    private readonly SomeDbContext _context == null!;

    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasQueryFilter(d => d.TenantId == _context.TenantId);
    }
}

Ограничения

Глобальные фильтры запросов имеют следующие ограничения:

  • Фильтры можно определить только для типа корневой сущности иерархии наследования.
  • В настоящее время EF Core не обнаруживает циклы в определениях глобальных фильтров запросов, поэтому следует проявлять осторожность при их определении. Если указано неправильно, циклы могут привести к бесконечным циклам во время перевода запросов.