Edit on GitHub

In this document

Introduction

In this article, I will explain how to add a custom data filter in Entity Framework Core.

We will create a filter for OrganizationUnit and filter entities inherited from IMayHaveOrganizationUnit interface automatically according to organization unit of logged in user.

We will use ASP.NET Core & JQuery template of ASP.NET Boilerplate. You can create a project on https://aspnetboilerplate.com/Templates and apply the steps below to see custom organization unit filter in action.

Create and Update Entities

Create an entity

Create an entity named Document inherited from IMayHaveOrganizationUnit (IMayHaveOrganizationUnit interface is defined in ABP Framework).

public class Document : Entity, IMayHaveOrganizationUnit
{
    public string Title { get; set; }

    public string Content { get; set; }

    public long? OrganizationUnitId { get; set; }
}

Add Document entity to your DbContext.

Update User class

Add OrganizationUnitId to User.cs. We will use OrganizationUnitId field of the User to filter Document entities.

public class User : AbpUser<User>
{
    public const string DefaultPassword = "123qwe";

    public static string CreateRandomPassword()
    {
        return Guid.NewGuid().ToString("N").Truncate(16);
    }

    public int? OrganizationUnitId { get; set; }

    public static User CreateTenantAdminUser(int tenantId, string emailAddress)
    {
        var user = new User
        {
            TenantId = tenantId,
            UserName = AdminUserName,
            Name = AdminUserName,
            Surname = AdminUserName,
            EmailAddress = emailAddress
        };

        user.SetNormalizedNames();

        return user;
    }
}

Add migration

Add migration using add-migration command and run update-database to apply changes to your database.

Create Claim

We need to store OrganizationUnitId of logged in user in claims, so we can get it in order to filter IMayHaveOrganizationUnit entities in our DbContext. In order to do that, override the CreateAsync method of UserClaimsPrincipalFactory class and add logged in users OrganizationUnitId to claims like below.

public class UserClaimsPrincipalFactory : AbpUserClaimsPrincipalFactory<User, Role>
{
    public UserClaimsPrincipalFactory(
        UserManager userManager,
        RoleManager roleManager,
        IOptions<IdentityOptions> optionsAccessor)
        : base(
                userManager,
                roleManager,
                optionsAccessor)
    {
    }

    public override async Task<ClaimsPrincipal> CreateAsync(User user)
    {
        var claim = await base.CreateAsync(user);
        claim.Identities.First().AddClaim(new Claim("Application_OrganizationUnitId", user.OrganizationUnitId.HasValue ? user.OrganizationUnitId.Value.ToString() : ""));

        return claim;
    }
}

Register Filter

Before filtering entities in our DbContext, we will register our filter, so we can disable it if we want to for some cases in our code.

Register filter in PreInitialize method in YourProjectNameEntityFrameworkModule to get it from current unit of work manager.

public override void PreInitialize()
{
    ...

    //register filter with default value
    Configuration.UnitOfWork.RegisterFilter("MayHaveOrganizationUnit", true);
}

Configure DbContext

We need to use the value of OrganizationUnitId we have added to claims to filter IMayHaveOrganizationUnit entities in our DbContext.

In order to do that, first add a field like below to your DbContext:

protected virtual int? CurrentOUId => GetCurrentUsersOuIdOrNull();

And define GetCurrentUsersOuIdOrNull method like below in your DbContext and also inject IPrincipalAccessor into your DbContext using propert injection;

public class CustomFilterSampleDbContext : AbpZeroDbContext<Tenant, Role, User, CustomFilterSampleDbContext>
{
    public DbSet<Document> Documents { get; set; }

    public IPrincipalAccessor PrincipalAccessor { get; set; }

    protected virtual int? CurrentOUId => GetCurrentUsersOuIdOrNull();

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

    protected virtual int? GetCurrentUsersOuIdOrNull()
	{
		var userOuClaim = PrincipalAccessor.Principal?.Claims.FirstOrDefault(c => c.Type == "Application_OrganizationUnitId");
		if (string.IsNullOrEmpty(userOuClaim?.Value))
		{
			return null;
		}

		return Convert.ToInt32(userOuClaim.Value);
	}	
}

After that, let's add a property to our DbContext in order to get if the MayHaveOrganizationUnit filter is enabled or not.

protected virtual bool IsOUFilterEnabled => CurrentUnitOfWorkProvider?.Current?.IsFilterEnabled("MayHaveOrganizationUnit") == true;

AbpDbContext defines two methods related to data filters. One is ShouldFilterEntity and the other one is CreateFilterExpression. ShouldFilterEntity method decides to filter an entity or not. CreateFilterExpression method creates filter expressions for entities to filter.

In order to filter entities inherited from IMayHaveOrganizationUnit, we need to override both methods.

First, override ShouldFilterEntity method like below;

protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
{
    if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
    {
        return true;
    }

    return base.ShouldFilterEntity<TEntity>(entityType);
}

Then, override CreateFilterExpression method like below;

protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>(ModelBuilder modelBuilder)
{
    var expression = base.CreateFilterExpression<TEntity>(modelBuilder);

    if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
    {
        Expression<Func<TEntity, bool>> mayHaveOUFilter = e => ((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId || (((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId) == IsOUFilterEnabled;
        expression = expression == null ? mayHaveOUFilter : CombineExpressions(expression, mayHaveOUFilter);
    }

    return expression;
}

Here is the final version of our DbContext:

public class CustomFilterSampleDbContext : AbpZeroDbContext<Tenant, Role, User, CustomFilterSampleDbContext>
{
    public DbSet<Document> Documents { get; set; }
	
    public IPrincipalAccessor PrincipalAccessor { get; set; }
	
    protected virtual int? CurrentOUId => GetCurrentUsersOuIdOrNull();
	
    protected virtual bool IsOUFilterEnabled => CurrentUnitOfWorkProvider?.Current?.IsFilterEnabled("MayHaveOrganizationUnit") == true;
	
    public CustomFilterSampleDbContext(DbContextOptions<CustomFilterSampleDbContext> options)
        : base(options)
    {
        
    }
	
    protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
    {
        if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
        {
            return true;
        }
        return base.ShouldFilterEntity<TEntity>(entityType);
    }
	
    protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>(ModelBuilder modelBuilder)
    {
        var expression = base.CreateFilterExpression<TEntity>(modelBuilder);
        if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
        {
            Expression<Func<TEntity, bool>> mayHaveOUFilter = e => ((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId || (((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId) == IsOUFilterEnabled;
            expression = expression == null ? mayHaveOUFilter : CombineExpressions(expression, mayHaveOUFilter);
        }
		
        return expression;
    }
	
    protected virtual int? GetCurrentUsersOuIdOrNull()
    {
        var userOuClaim = PrincipalAccessor.Principal?.Claims.FirstOrDefault(c => c.Type == "Application_OrganizationUnitId");
        if (string.IsNullOrEmpty(userOuClaim?.Value))
        {
            return null;
        }
		
        return Convert.ToInt32(userOuClaim.Value);
    }
}

Using User-defined function mapping for global filters

Using User-defined function mapping for global filters will gain performance improvements.

To use this feature, you need to change your DbContext like below:

public class CustomFilterSampleDbContext : AbpZeroDbContext<Tenant, Role, User, CustomFilterSampleDbContext>
{
    public DbSet<Document> Documents { get; set; }
	
    public IPrincipalAccessor PrincipalAccessor { get; set; }
	
    protected virtual int? CurrentOUId => GetCurrentUsersOuIdOrNull();
	
    protected virtual bool IsOUFilterEnabled => CurrentUnitOfWorkProvider?.Current?.IsFilterEnabled("MayHaveOrganizationUnit") == true;
	
    public CustomFilterSampleDbContext(DbContextOptions<CustomFilterSampleDbContext> options)
        : base(options)
    {
        
    }
	
    protected override bool ShouldFilterEntity<TEntity>(IMutableEntityType entityType)
    {
        if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
        {
            return true;
        }
        return base.ShouldFilterEntity<TEntity>(entityType);
    }
	
    protected override Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>(ModelBuilder modelBuilder)
    {
        var expression = base.CreateFilterExpression<TEntity>(modelBuilder);
        if (typeof(IMayHaveOrganizationUnit).IsAssignableFrom(typeof(TEntity)))
        {
            Expression<Func<TEntity, bool>> mayHaveOUFilter = e => ((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId || (((IMayHaveOrganizationUnit)e).OrganizationUnitId == CurrentOUId) == IsOUFilterEnabled;

            if (UseAbpQueryCompiler())
            {
                var abpEfCoreCurrentDbContext = this.GetService<AbpEfCoreCurrentDbContext>();
                mayHaveOUFilter = e => MayHaveOrganizationUnitFilter(((IMayHaveOrganizationUnit)e).OrganizationUnitId, CurrentOUId, true);
                modelBuilder.HasDbFunction(typeof(AbpProjectNameDbContext).GetMethod(nameof(MayHaveOrganizationUnitFilter), new []{ typeof(long?), typeof(long?), typeof(bool) })!)
                    .HasTranslation(args =>
                    {
                        // (long? organizationUnitId, long? currentOUId, bool boolParam)
                        var organizationUnitId = args[0];
                        var currentOUId = args[1];
                        var boolParam = args[2];

                        if (abpEfCoreCurrentDbContext.Context?.As<AbpProjectNameDbContext>().IsOUFilterEnabled == true)
                        {
                            // organizationUnitId == currentOUId
                            return new SqlBinaryExpression(
                                ExpressionType.Equal,
                                organizationUnitId,
                                currentOUId,
                                boolParam.Type,
                                boolParam.TypeMapping);
                        }

                        // empty where sql
                        return new SqlConstantExpression(Expression.Constant(true), boolParam.TypeMapping);
                    });
            }

            expression = expression == null ? mayHaveOUFilter : CombineExpressions(expression, mayHaveOUFilter);
        }

        return expression;
    }

    public static bool MayHaveOrganizationUnitFilter(long? organizationUnitId, long? currentOUId, bool boolParam)
    {
        throw new NotSupportedException(DbFunctionNotSupportedExceptionMessage);
    }

    public override string GetCompiledQueryCacheKey()
    {
        return $"{base.GetCompiledQueryCacheKey()}:{CurrentOUId}:{IsOUFilterEnabled}";
    }
	
    protected virtual int? GetCurrentUsersOuIdOrNull()
    {
        var userOuClaim = PrincipalAccessor.Principal?.Claims.FirstOrDefault(c => c.Type == "Application_OrganizationUnitId");
        if (string.IsNullOrEmpty(userOuClaim?.Value))
        {
            return null;
        }
		
        return Convert.ToInt32(userOuClaim.Value);
    }
}
Make User-defined function mapping compatible with DevExtreme.AspNet.Data's IAsyncAdapter

If you are using DevExtreme.AspNet.Data and User-defined function mapping, You need to create a custom adapter to compatible with IAsyncAdapter.

var adapter = new AbpDevExtremeAsyncAdapter();
CustomAsyncAdapters.RegisterAdapter(typeof(AbpEntityQueryProvider), adapter);
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using DevExtreme.AspNet.Data.Async;

namespace Abp.EntityFrameworkCore;

public class AbpDevExtremeAsyncAdapter : IAsyncAdapter
{
    private readonly MethodInfo _countAsyncMethod;
    private readonly MethodInfo _toListAsyncMethod;

    public AbpDevExtremeAsyncAdapter()
    {
        var extensionsType = Type.GetType("Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions, Microsoft.EntityFrameworkCore");
        _countAsyncMethod = FindQueryExtensionMethod(extensionsType, "CountAsync");
        _toListAsyncMethod = FindQueryExtensionMethod(extensionsType, "ToListAsync");
    }

    public Task<int> CountAsync(IQueryProvider provider, Expression expr, CancellationToken cancellationToken)
    {
        return InvokeCountAsync(_countAsyncMethod, provider, expr, cancellationToken);
    }

    public Task<IEnumerable<T>> ToEnumerableAsync<T>(IQueryProvider provider, Expression expr, CancellationToken cancellationToken)
    {
        return InvokeToListAsync<T>(_toListAsyncMethod, provider, expr, cancellationToken);
    }

    static MethodInfo FindQueryExtensionMethod(Type extensionsType, string name)
    {
        return extensionsType.GetMethods().First(m =>
        {
            if (!m.IsGenericMethod || m.Name != name)
            {
                return false;
            }
            var parameters = m.GetParameters();
            return parameters.Length == 2 && parameters[1].ParameterType == typeof(CancellationToken);
        });
    }

    static Task<int> InvokeCountAsync(MethodInfo method, IQueryProvider provider, Expression expr, CancellationToken cancellationToken)
    {
        var countArgument = ((MethodCallExpression)expr).Arguments[0];
        var query = provider.CreateQuery(countArgument);
        return (Task<int>)InvokeQueryExtensionMethod(method, query.ElementType, query, cancellationToken);
    }

    static async Task<IEnumerable<T>> InvokeToListAsync<T>(MethodInfo method, IQueryProvider provider, Expression expr, CancellationToken cancellationToken)
    {
        return await (Task<List<T>>)InvokeQueryExtensionMethod(method, typeof(T), provider.CreateQuery(expr), cancellationToken);
    }

    static object InvokeQueryExtensionMethod(MethodInfo method, Type elementType, IQueryable query, CancellationToken cancellationToken)
    {
        return method.MakeGenericMethod(elementType).Invoke(null, new object[] { query, cancellationToken });
    }
}

Testing the Filter

In order to test the MayHaveOrganizationUnit filter, create an organization unit and set its UserId = 2 (Id of the Default Tenant's admin user) and TenantId = 1 (Id of the Default Tenant). Then, create document records on the database as well. Set OrganizationUnitId of Default Tenant's admin user and document(s) you have created with id of the created organization unit.

Getting data from database in HomeController:

[AbpMvcAuthorize]
public class HomeController : CustomFilterSampleControllerBase
{
    private readonly IRepository<Document> _documentRepository;

    public HomeController(IRepository<Document> documentRepository)
    {
        _documentRepository = documentRepository;
    }

    public ActionResult Index()
    {
        var documents = _documentRepository.GetAllList();
        var documentTitles = string.Join(",", documents.Select(e => e.Title).ToArray());

        return Content(documentTitles);
    }
}

When you log in as host user you should see an emtpy page. But if you log in as admin user of Default Tenant, you will see the document titles as below:

Document Titles

Disable filter

You can disable filter like following:

[AbpMvcAuthorize]
public class HomeController : CustomFilterSampleControllerBase
{
    private readonly IRepository<Document> _documentRepository;
    private readonly IUnitOfWorkManager _unitOfWorkManager;

    public HomeController(IRepository<Document> documentRepository, IUnitOfWorkManager unitOfWorkManager)
    {
        _documentRepository = documentRepository;
        _unitOfWorkManager = unitOfWorkManager;
    }

    public ActionResult Index()
    {
        using (_unitOfWorkManager.Current.DisableFilter("MayHaveOrganizationUnit"))
        {
            var documents = _documentRepository.GetAllList();
            var documentTitles = string.Join(",", documents.Select(e => e.Title).ToArray());

            return Content(documentTitles);
        }
    }
}

In this case, all document records will be retrieved from database regardless of the logged in users OrganizationUnitId.