EF Core 資料過濾

2022-07-21 15:00:33

1 前言

本文致力於將一種動態資料過濾的方案描述出來(基於 EF Core 官方的資料篩選器),實現自動註冊,多個條件過濾,單條件禁用(實際上是參考ABP的原始碼),並儘量讓程式碼保持 EF Core 的原使用風格。

1.1 本文的脈絡

會在一開始,講述資料過濾的場景以及基本的實現思路。

隨後列出 EF Core 官方的資料查詢篩選器例子。

最後將筆者的方案按功能(自動註冊,多個條件過濾,單條件禁用)逐一實現出來。

1.2 資料過濾的場景

一般我們會有這樣的場景,可能需要資料過濾:

  • 軟刪
  • 多租戶
  • 通用資料許可權(資料過濾)

如軟刪,我們一般會希望,我們查詢出來的資料,是過濾掉被刪除資料的,可能我們會這樣寫:

var users = db.User.Where(u => !u.IsDeleted).ToList();

但是如果資料過濾全靠人工編寫,那會是一件很煩的事,有時候甚至會忘記寫。而且如果以後發生了什麼需求變化,需要修改資料過濾的程式碼,到時候是到處修改,也是很煩的一件事。

如果能把資料過濾統一管理起來,這樣不但不用重複無意義的工作,而且以後需要修改的時候,改一處地方即可。


2 EF Core 查詢篩選器

2.1 介紹

EF Core 官方提供的查詢篩選器(Query Filter)能滿足我們過濾資料的基本需求,下面介紹一下這種篩選器。

EF Core 官方的查詢篩選器,是在 DbContext 的 OnModelCreating 中定義的,且每個實體只能擁有一個篩選器(如定義了多個篩選器,則只會生效最後一個)。

篩選器預設是啟用的,如要禁用,需要在查詢過程中使用 IgnoreQueryFilters 方法,如:

var users = db.User.IgnoreQueryFilters().ToList();

2.2 基本使用

具體可以自行翻查官方檔案:全域性查詢篩選器

(1)定義帶有軟刪欄位的實體

public class TestDelete
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsDeleted { get; set; } = false;
}

(2)註冊篩選器

使用 HasQueryFilter API 在 OnModelCreating 中設定查詢篩選器。

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<TestDelete>().HasQueryFilter(e => !e.IsDeleted);
}

(3)查詢中使用

直接查詢:

var deletes = _context.Set<TestDelete>().ToList();

將生成如下SQL:

SELECT [t].[Id], [t].[IsDeleted], [t].[Name]
FROM [TestDelete] AS [t]
WHERE [t].[IsDeleted] = CAST(0 AS bit)

查詢結果將會過濾掉已刪除的資料。

(4)禁用篩選器

使用 IgnoreQueryFilters API 禁用篩選器:

var deletes = _context.Set<TestDelete>()IgnoreQueryFilters().ToList();

將生成如下SQL:

SELECT [t].[Id], [t].[IsDeleted], [t].[Name]
FROM [TestDelete] AS [t]

將不會過濾資料。

2.3 限制

EF Core 查詢篩選器的限制很明顯:

  • 只能生效最後一個
  • 一旦禁用,將禁用所有過濾條件

只能生效最後一個這個,可以通過拼湊多個條件的 Expression 來解決。

禁用過濾器這個,只能通過特定的手段來實現單個條件的禁用了。


3 自定義資料過濾

3.1 目標

將實現這些功能:

  • 自動註冊實體、篩選器等

  • 多個條件過濾

  • 單條件禁用

3.2 自動註冊實體

完成這個功能以後,將不需要自己一個一個去註冊實體,不需要重複以下這句程式碼:

builder.Entity<TestDelete>();

(1)基礎實體準備

準備一個 EntityBase 作為所有實體的父類別,所有繼承該類的非 abstract 類都將被註冊為實體。

public abstract class EntityBase {}

public abstract class EntityBase<TKey> : EntityBase
    where TKey : struct
{
    public TKey Id { get; set; }
}

(2)自動註冊實體實現

筆者自定義的 DbContext 名為 EDbContext,下面將多次使用到這個上下文。

OnModelCreating 中編寫如下程式碼:

// 獲取程式集
Assembly assembly = typeof(EDbContext).Assembly;
// 獲取所有繼承自 EntityBase 的非 abstract 類
List<Type> entityTypes = assembly.GetTypes()
    .Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
    .ToList();

// 註冊實體
foreach(Type entityType in entityTypes)
{
    builder.Entity(entityType);
}

3.3 自定註冊篩選器

完成這個功能以後,將不需要自己一個一個去註冊一些篩選器,如:不需要重複以下這句程式碼:

builder.Entity<TestDelete>().HasQueryFilter(e => !e.IsDeleted);

(1)基礎實體準備

定義一個軟刪的 interface,所有需要軟刪功能的實體,都去實現這個介面:

// 定義軟刪介面
interface ISoftDelete
{
    public bool IsDeleted { get; set; }
}
// TestDelete 相應修改為
public class TestDelete : EntityBase<int>, ISoftDelete
{
    public string? Name { get; set; }
    public bool IsDeleted { get; set; } = false;
}

(2)自動註冊實現

EDbContext 的程式碼變為如下(增加了一個 ConfigureFilters 方法):

因為 ConfigureFilters 是一個泛型方法,需要做一些特殊處理。

protected override void OnModelCreating(ModelBuilder builder)
{
    Assembly assembly = Assembly.GetExecutingAssembly();
    List<Type> entityTypes = assembly.GetTypes()
        .Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
        .ToList();

    // 特殊處理:獲取 ConfigureFilters
    MethodInfo? configureFilters = typeof(EDbContext).GetMethod(
        nameof(ConfigureFilters),
        BindingFlags.Instance | BindingFlags.NonPublic
    );

    if (configureFilters == null) throw new ArgumentNullException(nameof(configureFilters));

    foreach(Type entityType in entityTypes)
    {
        builder.Entity(entityType);

        // 如果實體實現了 ISoftDelete 介面,則自動註冊軟刪篩選器
        if (typeof(ISoftDelete).IsAssignableFrom(entityType))
        {
            // 特殊處理:呼叫 ConfigureFilters
            configureFilters
                .MakeGenericMethod(entityType)
                .Invoke(this, new object[] { builder });
        }
    }
}

// 自定義設定篩選器方法
protected virtual void ConfigureFilters<TEntity>(ModelBuilder builder)
    where TEntity : class
{
    Expression<Func<TEntity, bool>> expression = e => !EF.Property<bool>(e, "IsDeleted");
    builder.Entity<TEntity>().HasQueryFilter(expression);
}

(3) 測試:自動註冊功能的測試

完成自動註冊以後,執行程式,看看過濾器是否有效果:

直接查詢:

var deletes = _context.Set<TestDelete>().ToList();

將生成如下SQL:

SELECT [t].[Id], [t].[IsDeleted], [t].[Name]
FROM [TestDelete] AS [t]
WHERE [t].[IsDeleted] = CAST(0 AS bit)

查詢結果將會過濾掉已刪除的資料。

可以看到,自動註冊是成功的!

3.4 實現:多個條件過濾

在這一小節中,將會實現多個條件過濾。

一般我們的程式中,除了軟刪,還可能有其他的需要統一管理的資料過濾,如:多租戶。

(1)基礎實體準備

準備一個多租戶的 interface,命名為 ITenant,所有需要多租戶控制的,都實現該介面。

並準備一個 TestTenant 同時繼承 ITenant 和 ISoftDelete。

為簡單處理,將 TenantId 預設值設定為 1

// 多租戶介面
public interface ITenant
{
    public int TenantId { get; set; }
}

public class TestTenant : EntityBase<int>, ITenant, ISoftDelete
{
    public string? Name { get; set; }
    public int TenantId { get; set; } = 1;
    public bool IsDeleted { get; set; } = false;
}

(2)合併表示式樹程式碼準備

因為涉及到兩個表示式樹(Expression)的合併,這裡準備了合併的程式碼(摘自ABP框架),放在 EDbContext 中即可:

關於表示式樹,個人也是不會,就不在這裡誤人子弟啦。

protected virtual Expression<Func<T, bool>> CombineExpressions<T>(Expression<Func<T, bool>> expression1, Expression<Func<T, bool>> expression2)
{
    var parameter = Expression.Parameter(typeof(T));

    var leftVisitor = new ReplaceExpressionVisitor(expression1.Parameters[0], parameter);
    var left = leftVisitor.Visit(expression1.Body);

    var rightVisitor = new ReplaceExpressionVisitor(expression2.Parameters[0], parameter);
    var right = rightVisitor.Visit(expression2.Body);

    return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(left, right), parameter);
}

class ReplaceExpressionVisitor : ExpressionVisitor
{
    private readonly Expression _oldValue;
    private readonly Expression _newValue;

    public ReplaceExpressionVisitor(Expression oldValue, Expression newValue)
    {
        _oldValue = oldValue;
        _newValue = newValue;
    }

    public override Expression Visit(Expression? node)
    {
        if (node == _oldValue)
        {
            return _newValue;
        }

        return base.Visit(node)!;
    }
}

(3)實現多個條件過濾

EDbContext 的程式碼變為如下:

修改了 OnModelCreatingConfigureFilters 的大部分程式碼:

protected override void OnModelCreating(ModelBuilder builder)
{
    Assembly assembly = typeof(EDbContext).Assembly;
    List<Type> entityTypes = assembly.GetTypes()
        .Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
        .ToList();

    MethodInfo? configureFilters = typeof(EDbContext).GetMethod(
        nameof(ConfigureFilters),
        BindingFlags.Instance | BindingFlags.NonPublic
    );

    if (configureFilters == null) throw new ArgumentNullException(nameof(configureFilters));

    foreach(Type entityType in entityTypes)
    {
        // 註冊實體
        builder.Entity(entityType);

        // 註冊篩選器
        configureFilters
            .MakeGenericMethod(entityType)
            .Invoke(this, new object[] { builder, entityType });
    }
}

protected virtual void ConfigureFilters<TEntity>(ModelBuilder builder, Type entityType)
	where TEntity : class
{
    Expression<Func<TEntity, bool>>? expression = null;

    if (typeof(ISoftDelete).IsAssignableFrom(entityType))
    {
        expression = e => !EF.Property<bool>(e, "IsDeleted");
    }

    if (typeof(ITenant).IsAssignableFrom(entityType))
    {
        Expression<Func<TEntity, bool>> tenantExpression = e => EF.Property<int>(e, "TenantId") == 1;
        expression = expression == null ? tenantExpression : CombineExpressions(expression, tenantExpression);
    }

    if (expression == null) return;

    builder.Entity<TEntity>().HasQueryFilter(expression);
}

(4)測試:多條件過濾

直接查詢:

var tenants = _context.Set<TestTenant>().ToList();

將生成如下SQL:

SELECT [t].[Id], [t].[IsDeleted], [t].[Name], [t].[TenantId]
FROM [TestTenant] AS [t]
WHERE ([t].[IsDeleted] = CAST(0 AS bit)) AND ([t].[TenantId] = 1)

查詢結果將會過濾掉已刪除,且租戶Id=1的資料。

3.5 實現:單條件禁用

直接使用 IgnoreQueryFilters 將會禁用篩選器,這裡希望有個控制,可以單個條件控制:

下面實現禁用軟刪篩選器:

(1)DbContext 變數控制

在 EDbContext 中新增一個屬性:

public class EDbContext : DbContext
{
    public bool IgnoreDeleteFilter { get; set; } = false;
    
    // 其他程式碼忽略
}

(2)修改篩選器

修改了 ConfigureFilters 的程式碼:

if (typeof(ISoftDelete).IsAssignableFrom(entityType))
{
    // 如果 IgnoreDeleteFilter 為 true,將跳過
    expression = e => IgnoreDeleteFilter || !EF.Property<bool>(e, "IsDeleted");
}

(3)測試:單條件禁用

測試語句如下:

_context.IgnoreDeleteFilter = true;
var tenants = _context.Set<TestTenant>().ToList();

生成如下SQL:

Executed DbCommand (1ms) [Parameters=[@__ef_filter__IgnoreDeleteFilter_0='?' (DbType = Boolean)], CommandType='Text', CommandTimeout='30']
SELECT [t].[Id], [t].[IsDeleted], [t].[Name], [t].[TenantId]
FROM [TestTenant] AS [t]
WHERE ((@__ef_filter__IgnoreDeleteFilter_0 = CAST(1 AS bit)) OR ([t].[IsDeleted] = CAST(0 AS bit))) AND ([t].[TenantId] = 1)

可以看到,原先的軟刪條件:

([t].[IsDeleted] = CAST(0 AS bit))

變成了:

((@__ef_filter__IgnoreDeleteFilter_0 = CAST(1 AS bit)) OR ([t].[IsDeleted] = CAST(0 AS bit)))

IgnoreDeleteFilter 為 true 時,將會禁用軟體的篩選條件。

查詢的資料,也確實將軟刪的資料給查了出來。


4 完整程式碼

第3節中,完整的程式碼如下:

Models

namespace EFCoreTest.Models;

public abstract class EntityBase { }

public abstract class EntityBase<TKey> : EntityBase
    where TKey : struct
{
    public TKey Id { get; set; }
}

interface ISoftDelete
{
    public bool IsDeleted { get; set; }
}

public class TestDelete : EntityBase<int>, ISoftDelete
{
    public string? Name { get; set; }
    public bool IsDeleted { get; set; } = false;
}

public interface ITenant
{
    public int TenantId { get; set; }
}

public class TestTenant : EntityBase<int>, ITenant, ISoftDelete
{
    public string? Name { get; set; }
    public int TenantId { get; set; }
    public bool IsDeleted { get; set; } = false;
}

EDbContext

using EFCoreTest.Models;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
using System.Reflection;

namespace EFCoreTest;

public class EDbContext : DbContext
{
    public bool IgnoreDeleteFilter { get; set; } = false;

    public EDbContext(DbContextOptions<EDbContext> options) : base(options) { }

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        base.OnConfiguring(options);
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        //// 基本註冊
        //builder.Entity<TestDelete>().HasQueryFilter(e => !e.IsDeleted);

        Assembly assembly = typeof(EDbContext).Assembly;
        //Assembly assembly = Assembly.GetExecutingAssembly();
        List<Type> entityTypes = assembly.GetTypes()
            .Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
            .ToList();

        MethodInfo? configureFilters = typeof(EDbContext).GetMethod(
            nameof(ConfigureFilters),
            BindingFlags.Instance | BindingFlags.NonPublic
        );

        if (configureFilters == null) throw new ArgumentNullException(nameof(configureFilters));

        foreach(Type entityType in entityTypes)
        {
            // 註冊實體
            builder.Entity(entityType);

            // 註冊篩選器
            configureFilters
                .MakeGenericMethod(entityType)
                .Invoke(this, new object[] { builder, entityType });
        }
    }

    protected virtual void ConfigureFilters<TEntity>(ModelBuilder builder, Type entityType)
            where TEntity : class
    {
        Expression<Func<TEntity, bool>>? expression = null;

        if (typeof(ISoftDelete).IsAssignableFrom(entityType))
        {
            expression = e => IgnoreDeleteFilter || !EF.Property<bool>(e, "IsDeleted");
        }

        if (typeof(ITenant).IsAssignableFrom(entityType))
        {
            Expression<Func<TEntity, bool>> tenantExpression = e => EF.Property<int>(e, "TenantId") == 1;
            expression = expression == null ? tenantExpression : CombineExpressions(expression, tenantExpression);
        }

        if (expression == null) return;

        builder.Entity<TEntity>().HasQueryFilter(expression);
    }

    protected virtual Expression<Func<T, bool>> CombineExpressions<T>(Expression<Func<T, bool>> expression1, Expression<Func<T, bool>> expression2)
    {
        var parameter = Expression.Parameter(typeof(T));

        var leftVisitor = new ReplaceExpressionVisitor(expression1.Parameters[0], parameter);
        var left = leftVisitor.Visit(expression1.Body);

        var rightVisitor = new ReplaceExpressionVisitor(expression2.Parameters[0], parameter);
        var right = rightVisitor.Visit(expression2.Body);

        return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(left, right), parameter);
    }

    class ReplaceExpressionVisitor : ExpressionVisitor
    {
        private readonly Expression _oldValue;
        private readonly Expression _newValue;

        public ReplaceExpressionVisitor(Expression oldValue, Expression newValue)
        {
            _oldValue = oldValue;
            _newValue = newValue;
        }

        public override Expression Visit(Expression? node)
        {
            if (node == _oldValue)
            {
                return _newValue;
            }

            return base.Visit(node)!;
        }
    }
}

測試 Controller

using EFCoreTest;
using EFCoreTest.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace QueryFilterTest.Controllers;

[ApiController]
[Route("[controller]/[action]")]
public class TestController : ControllerBase
{
    private readonly EDbContext _context;
    private readonly ILogger<TestController> _logger;

    public TestController(ILogger<TestController> logger, EDbContext context)
    {
        _logger = logger;
        _context = context;
    }

    [HttpGet]
    public List<TestDelete> GetDeleteBase()
    {
        // 過濾 IsDeleted == true 的資料
        var deletes = _context.Set<TestDelete>().ToList();

        // 忽略篩選器,不過濾資料
        var allDeletes = _context.Set<TestDelete>().IgnoreQueryFilters().ToList();

        return allDeletes;
    }

    [HttpGet]
    public List<TestTenant> GetTenant()
    {
        // 軟刪、多租戶 篩選器同時作用
        var tenants = _context.Set<TestTenant>().ToList();

        // 禁用所有的篩選器
        var allTenants = _context.Set<TestTenant>().IgnoreQueryFilters().ToList();

        return allTenants;
    }

    [HttpGet]
    public List<TestTenant> GetTenantIgnoreDelete()
    {
        // 禁用軟體篩選器
        _context.IgnoreDeleteFilter = true;
        var tenants = _context.Set<TestTenant>().ToList();
        return tenants;
    }
}

完整專案程式碼:

Gitee:https://gitee.com/lisheng741/testnetcore/tree/master/EFCore/QueryFilterTest

Github:https://github.com/lisheng741/testnetcore/tree/master/EFCore/QueryFilterTest


參考來源

ABP 原始碼

EF Core 官方檔案:全域性查詢篩選器

EntityFramework Core 2.0全域性過濾(HasQueryFilter)