全局查询筛选器

全局查询筛选器允许将筛选器附加到实体类型,并在执行该实体类型的查询时应用该筛选器;将它们视为在查询实体类型时添加的附加 LINQ Where 运算符。 此类筛选器在各种情况下非常有用。

小窍门

可以在 GitHub 上查看本文 的示例

基本示例 - 软删除

在某些情况下,而不是从数据库中删除行,最好设置一个 IsDeleted 标志来将行标记为已删除;此模式称为 软删除。 软删除允许在需要时取消删除行,或者保留已删除行仍可访问的审计跟踪。 默认情况下,全局查询筛选器可用于筛选出软删除的行,同时仍允许你通过禁用特定查询的筛选器在特定位置访问它们。

若要启用软删除,请在我们的 IsDeleted 博客类型中添加一个属性。

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

    public string Name { get; set; }
}

我们现在在 HasQueryFilter 中使用 OnModelCreating API 设置全局查询筛选器。

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

我们现在可以像往常一样查询实体 Blog ;配置的筛选器将确保所有查询默认都会筛选出所有真实 IsDeleted 实例。

请注意,此时必须手动设置 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);
}

这样,就可以使用 EF API 来像往常一样删除实体实例,并改为将其软删除。

使用上下文数据 - 多租户架构

全局查询筛选器的另一种主流场景是 多租户,在这种场景中,应用程序会将不同用户的数据存储在同一个表中。 在这种情况下,通常有一列 租户 ID 用于将行与特定租户关联,全局查询过滤器可以自动筛选当前租户的相关行。 默认情况下,这为查询提供强租户隔离,无需考虑在每个查询中筛选租户。

与软删除不同,多租户需要知道 当前 租户 ID;此值通常确定,例如当用户通过 Web 进行身份验证时。 出于 EF 的目的,租户 ID 必须在上下文实例上可用,以便全局查询筛选器可以引用它并在查询时使用它。 让我们在上下文类型的构造函数中接受一个 tenantId 参数,并引用筛选器中的参数:

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

这会强制任何构造上下文的人指定其关联的租户 ID,并确保默认情况下从查询中只返回具有该 ID 的 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);

这样就可以单独管理每个筛选器,包括有选择地禁用一个筛选器,但不能禁用另一个筛选器。

禁用筛选器

可使用 IgnoreQueryFilters 运算符对各个 LINQ 查询禁用筛选器:

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 实体。 由于需要在 BlogPost 之间导航,因此在构造查询时,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"));

另一种方法是在 BlogPost 实体类型上指定一致的筛选器;一旦对 BlogPost 都应用了匹配的筛选器,可能最终处于意外状态的 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

如果查询筛选器需要访问租户 ID 或类似的上下文信息,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 不会检测全局查询筛选器定义中的周期,因此在定义它们时应小心。 如果指定不正确,则循环可能会导致查询转换期间出现无限循环。