Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

EF Core 2.0.0 Query Filter is Caching TenantId (Updated for 2.0.1+)

I'm building a multi-tenant application, and am running into difficulties with what I think is EF Core caching the tenant id across requests. The only thing that seems to help is constantly rebuilding the application as I sign in and out of tenants.

I thought it may have something to do with the IHttpContextAccessor instance being a singleton, but it can't be scoped, and when I sign in and out without rebuilding I can see the tenant's name change at the top of the page, so it's not the issue.

The only other thing I can think of is that EF Core is doing some sort of query caching. I'm not sure why it would be considering that it's a scoped instance and it should be getting rebuild on every request, unless I'm wrong, which I probably am. I was hoping it would behave like a scoped instance so I could simply inject the tenant id at model build time on each instance.

I'd really appreciate it if someone could point me in the right direction. Here's my current code:

TenantProvider.cs

public sealed class TenantProvider :
    ITenantProvider {
    private readonly IHttpContextAccessor _accessor;

    public TenantProvider(
        IHttpContextAccessor accessor) {
        _accessor = accessor;
    }

    public int GetId() {
        return _accessor.HttpContext.User.GetTenantId();
    }
}

...which is injected into TenantEntityConfigurationBase.cs where I use it to setup a global query filter.

internal abstract class TenantEntityConfigurationBase<TEntity, TKey> :
    EntityConfigurationBase<TEntity, TKey>
    where TEntity : TenantEntityBase<TKey>
    where TKey : IEquatable<TKey> {
    protected readonly ITenantProvider TenantProvider;

    protected TenantEntityConfigurationBase(
        string table,
        string schema,
        ITenantProvider tenantProvider) :
        base(table, schema) {
        TenantProvider = tenantProvider;
    }

    protected override void ConfigureFilters(
        EntityTypeBuilder<TEntity> builder) {
        base.ConfigureFilters(builder);

        builder.HasQueryFilter(
            e => e.TenantId == TenantProvider.GetId());
    }

    protected override void ConfigureRelationships(
        EntityTypeBuilder<TEntity> builder) {
        base.ConfigureRelationships(builder);

        builder.HasOne(
            t => t.Tenant).WithMany().HasForeignKey(
            k => k.TenantId);
    }
}

...which is then inherited by all other tenant entity configurations. Unfortunately it doesn't seem to work as I had planned.

I have verified that the tenant id being returned by the user principal is changing depending on what tenant user is logged in, so that's not the issue. Thanks in advance for any help!

Update

For a solution when using EF Core 2.0.1+, look at the not-accepted answer from me.

Update 2

Also look at Ivan's update for 2.0.1+, it proxies in the filter expression from the DbContext which restores the ability to define it once in a base configuration class. Both solutions have their pros and cons. I've opted for Ivan's again because I just want to leverage my base configurations as much as possible.

like image 340
Gup3rSuR4c Avatar asked Nov 13 '17 15:11

Gup3rSuR4c


People also ask

Does EF core cache queries?

It's important to clarify that EF Core already automatically compiles and caches your queries using a hashed representation of the query expression. When your code needs to reuse a previously executed query, EF Core uses the hash to lookup and return the compiled query from the cache.

Does EF core cache data by default?

The Cache: The memory cache is used by default. The Cache Key: The cache key is created by combining a cache prefix, all cache tags and the query expression. The Query Materialized: The query is materialized by either using "ToList()" method or "Execute()" method for query deferred.

What is Dbcontext pooling?

Context pooling works by reusing the same context instance across requests; this means that it's effectively registered as a Singleton, and the same instance is reused across multiple requests (or DI scopes).


2 Answers

Currently (as of EF Core 2.0.0) the dynamic global query filtering is quite limited. It works only if the dynamic part is provided by direct property of the target DbContext derived class (or one of its base DbContext derived classes). Exactly as in the Model-level query filters example from the documentation. Exactly that way - no method calls, no nested property accessors - just property of the context. It's sort of explained in the link:

Note the use of a DbContext instance level property: TenantId. Model-level filters will use the value from the correct context instance. i.e. the one that is executing the query.

To make it work in your scenario, you have to create a base class like this:

public abstract class TenantDbContext : DbContext
{
    protected ITenantProvider TenantProvider;
    internal int TenantId => TenantProvider.GetId();
}

derive your context class from it and somehow inject the TenantProvider instance into it. Then modify the TenantEntityConfigurationBase class to receive TenantDbContext:

internal abstract class TenantEntityConfigurationBase<TEntity, TKey> :
    EntityConfigurationBase<TEntity, TKey>
    where TEntity : TenantEntityBase<TKey>
    where TKey : IEquatable<TKey> {
    protected readonly TenantDbContext Context;

    protected TenantEntityConfigurationBase(
        string table,
        string schema,
        TenantDbContext context) :
        base(table, schema) {
        Context = context;
    }

    protected override void ConfigureFilters(
        EntityTypeBuilder<TEntity> builder) {
        base.ConfigureFilters(builder);

        builder.HasQueryFilter(
            e => e.TenantId == Context.TenantId);
    }

    protected override void ConfigureRelationships(
        EntityTypeBuilder<TEntity> builder) {
        base.ConfigureRelationships(builder);

        builder.HasOne(
            t => t.Tenant).WithMany().HasForeignKey(
            k => k.TenantId);
    }
}

and everything will work as expected. And remember, the Context variable type must be a DbContext derived class - replacing it with interface won't work.

Update for 2.0.1: As @Smit pointed out in the comments, v2.0.1 removed most of the limitations - now you can use methods and sub properties.

However, it introduced another requirement - the dynamic expression must be rooted at the DbContext.

This requirement breaks the above solution, since the expression root is TenantEntityConfigurationBase<TEntity, TKey> class, and it's not so easy to create such expression outside the DbContext due to lack of compile time support for generating constant expressions.

It could be solved with some low level expression manipulation methods, but the easier in your case would be to move the filter creation in generic instance method of the TenantDbContext and call it from the entity configuration class.

Here are the modifications:

TenantDbContext class:

internal Expression<Func<TEntity, bool>> CreateFilter<TEntity, TKey>()
    where TEntity : TenantEntityBase<TKey>
    where TKey : IEquatable<TKey>
{
    return e => e.TenantId == TenantId;
}

TenantEntityConfigurationBase<TEntity, TKey> class:

builder.HasQueryFilter(Context.CreateFilter<TEntity, TKey>());
like image 196
Ivan Stoev Avatar answered Nov 05 '22 13:11

Ivan Stoev


Answer for 2.0.1+

So, the day I got it work, EF Core 2.0.1 was released. As soon as I updated, this solution came crashing down. After a very long thread over here, it turned out that it was really a fluke that it was working in 2.0.0.

Officially for 2.0.1 and beyond any query filters that depend on an outside value, like the tenant id in my case, must be defined in the OnModelCreating method and must reference a property on the DbContext. The reason is because on first run of the app or first call into EF all EntityTypeConfiguration classes are processed and their results are cached regardless of how many times the DbContext is instanced.

That's why defining the query filters in the OnModelCreating method works because it's a fresh instance and the filter lives and dies with it.

public class MyDbContext : DbContext {
    private readonly ITenantService _tenantService;

    private int TenantId => TenantService.GetId();

    public DbSet<User> Users { get; set; }

    public MyDbContext(
        DbContextOptions options,
        ITenantService tenantService) {
        _tenantService = tenantService;
    }

    protected override void OnModelCreating(
        ModelBuilder modelBuilder) {
        modelBuilder.Entity<User>().HasQueryFilter(
            u => u.TenantId == TenantId);
    }
}
like image 33
Gup3rSuR4c Avatar answered Nov 05 '22 11:11

Gup3rSuR4c