1

I'm working in .Net Core 2.1, creating an application which uses multitenancy. I'm applying default filters to my context. However, Entity Framework is not properly leveraging parametrized queries.

I have a configuration options being passed to my context to apply constraints which look like so:

public class ContextAuthorizationOptions : DbAuthorizationOptions<AstootContext>
{
    protected IUserAuthenticationManager _userManager;
    protected int _userId => this._userManager.GetUserId();

    public ContextAuthorizationOptions(IUserAuthenticationManager authenticationManager, IValidatorProvider validatorProvider) 
        : base(validatorProvider)
    {
        this._userManager = authenticationManager;

        ConstraintOptions.SetConstraint<Message>(x => x.Conversation.ConversationSubscriptions
                                         .Select(cs => cs.UserId)
                                         .Any(userId => userId == this._userId));
    }        
}

As you can see my query uses a property to store the userId value. My context takes in the constraint options ad applies them OnModels creating like so:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var constraintOptions = this._authorizationOptions.ConstraintOptions;
    constraintOptions.ApplyStaticConstraint(modelBuilder);
    base.OnModelCreating(modelBuilder);
}

My Model options look like so:

protected List<Action<ModelBuilder>> _constraints = new List<Action<ModelBuilder>>();

public void SetConstraint<T>(Expression<Func<T, bool>> constraint)
    where T: class
{
    this._constraints.Add(m => m.Entity<T>().HasQueryFilter(constraint));
}

public void ApplyStaticConstraint(ModelBuilder modelBuilder)
{
    foreach(var applyConstraint in this._constraints)
    {
        applyConstraint(modelBuilder);
    }
}

Since my filters are using properties I would expect this to generate a parameterized query yet when dumping to messages table to list it generates this SQL

SELECT [x].[Id], [x].[ConversationId], [x].[Created], [x].[MessageText], [x].[SenderUserId]
FROM [Messages] AS [x]
INNER JOIN [Conversations] AS [x.Conversation] ON [x].[ConversationId] = [x.Conversation].[Id]
WHERE EXISTS (
    SELECT 1
    FROM [ConversationSubscriptions] AS [cs]
    WHERE ([cs].[UserId] = 2005) AND ([x.Conversation].[Id] = [cs].[ConversationId]))

How can I modify my implementation so Entity Framework Core can leverage query caching?

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
johnny 5
  • 19,893
  • 50
  • 121
  • 195
  • Is this full framework Entity Framework, or Entity Framework Core 2.1? – Johnathon Sullinger Jun 15 '18 at 03:00
  • EntityFramework Core 2.1 – johnny 5 Jun 15 '18 at 03:01
  • Why not using https://learn.microsoft.com/en-us/ef/core/querying/filters ? – Tseng Jun 15 '18 at 06:11
  • Is this working at all? With standard EF Core db context model caching, `OnModelCreating` is called just once, isn't it? – Ivan Stoev Jun 15 '18 at 08:18
  • @IvanStoev yeah should this works fine, IUserManager is a singleton which grabs my userid using the HttpContext – johnny 5 Jun 15 '18 at 11:25
  • @IvanStoev the only reason I apply the logic through this class is because, I have another section of rules that are applied dynamically in the constructor of my context on initialization using EntityFramework Plus, but I don’t like to use that for rules that will be universal because it’s a lot slower – johnny 5 Jun 15 '18 at 11:28
  • Anyway, in order to get variable support, the expressions must be rooted to the db context - see https://stackoverflow.com/questions/47268072/ef-core-2-0-0-query-filter-is-caching-tenantid-updated-for-2-0-1/47270953#47270953, https://stackoverflow.com/questions/48036547/db-resource-authorization-in-ef-core/48036759#48036759 and the documentation example. I have no idea why is that requirement, but if you don't do that, all your variables are converted to constants. In your example, `this._userId` must somehow become `this._authorizationOptions.ConstraintOptions._userId` :) – Ivan Stoev Jun 15 '18 at 11:37
  • @Tseng, there documentation is lacking, it actually looks wrong completely, it is not clear who sets the _tenantId, and the filter looks like it will always evaluate to true because they just checking if the property they’ve back by _tenantId is equivalent _tenantId – johnny 5 Jun 15 '18 at 11:40
  • I agree that the documentation is unclear. The **Tip** says: *"Note the use of a DbContext instance level field: _tenantId used to set the current tenant. Model-level filters will use the value from the correct context instance. I.e. The instance that is executing the query."*. What it doesn't say is that this is *the only way* to get dynamic (or variable) filter. The `tenantId` in their example is supposed to be provided/injected via constructor. – Ivan Stoev Jun 15 '18 at 11:43
  • @IvanStoev I’m pretty sure they fixed that in the 2.1 version, I had a bug related to this, they now support [indirection](https://github.com/aspnet/EntityFrameworkCore/issues/10301) – johnny 5 Jun 15 '18 at 11:47
  • @IvanStoev I’m still confused on how the query filter in the documentation would even work, are they just checking to see if the _tenantId equals itself? Shouldn’t they be pointing to a property existing on the entity? – johnny 5 Jun 15 '18 at 11:49
  • @johnny5 They did fix it *partially* by removing the initial requirement of the criteria being a *direct* field/property of the context. But the *rooted* requirement still remains - note how the `Indirection` is a field of their db context. – Ivan Stoev Jun 15 '18 at 11:52
  • @IvanStoev interesting, When I get back from work I’ll move the tenantId to the context and see if it works, if it does I’ll let you know, so if you want to post this as an answer, I can credit you – johnny 5 Jun 15 '18 at 11:57

1 Answers1

2

By some reason that only EF Core designers can explain, the query filter expressions are treated differently than the other query expressions. In particular, all variables which are not rooted to the target db context are evaluated and converted to constants. Rooted term has evolved from simply direct field/property of the context to more relaxed rules explained in #10301: Query: QueryFilter with EntityTypeConfiguration are failing to inject current context values Design meeting notes:

Patterns of configuration which would capture context correctly and inject current instance values

  • Defining filter in OnModelCreating
  • Defining filter in EntityTypeConfiguration by passing context through constructor
  • Defining filter using method (inside/outside DbContext or extension method) where context is passed as parameter. Any of above where context is wrapped inside another object type and that type is being passed around.

Apart from above we will parametrize any kind of call on DbContext i.e. property/field access, method call, going through multiple levels.

The bullet #3 ("Defining filter using method (inside/outside DbContext or extension method) where context is passed as parameter.") leads me to a relatively simple generic solution.

Add the following simple class:

public static class Filter
{
    public static T Variable<T>(this DbContext context, T value) => value;
}

Modify your options class like so:

protected List<Action<ModelBuilder, DbContext>> _constraints = new List<Action<ModelBuilder, DbContext>>();

public void SetConstraint<T>(Func<DbContext, Expression<Func<T, bool>>> constraint)
    where T : class
{
    this._constraints.Add((mb, c) => mb.Entity<T>().HasQueryFilter(constraint(c)));
}

public void ApplyStaticConstraint(ModelBuilder modelBuilder, DbContext context)
{
    foreach (var applyConstraint in this._constraints)
    {
        applyConstraint(modelBuilder, context);
    }
}

the SetConstraint call like so (note wrapping the this._userId into Variable method call):

ConstraintOptions.SetConstraint<Message>(c => x => x.Conversation.ConversationSubscriptions
    .Select(cs => cs.UserId)
    .Any(userId => userId == c.Variable(this._userId)));

and finally the ApplyStaticConstraint call:

constraintOptions.ApplyStaticConstraint(modelBuilder, this);

Now the query will use parameter instead of a constant value.

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
  • this works great but I'm a bit confused how. The only thing that doesn't make sense to me, is how EF can translate the `T Variable(this DbContext context, T value) => value;` Function call? Is this because its leveraging Lambda syntax? – johnny 5 Jun 16 '18 at 04:00
  • I figured this would throw unsupported method, but maybe because it’s doing a simple binary expression it knows not to call how to translate the value – johnny 5 Jun 16 '18 at 04:21
  • They don't translate it. As I understand, they replace it with their own variable, and set the value by evaluating the original expression before executing the query. – Ivan Stoev Jun 16 '18 at 06:09
  • is this new to core, or it’s fine because unsupported methods exception will only throw if you try to use a method on a variable on which isn’t in memory yet? – johnny 5 Jun 16 '18 at 14:01
  • @johnny5 As I pointed out, the query filter expression is processed specially. If you use similar method in normal query, you'd get not supported exception. The key point is in the bullet - a **method** having `DbContext` as parameter. In our sample, `c.Variable(this._userId)` expression is extracted and replaced with something. Then the extracted expression is **evaluated** (executed) passing the instance of the db context executing the query. In this case we are not using the context, but they don't know that. The whole trick is to tell them - hey, don't evaluate this once as constant... – Ivan Stoev Jun 16 '18 at 15:27
  • , use variable and call whatever I'm giving you when you need a value. – Ivan Stoev Jun 16 '18 at 15:28
  • 1
    Thanks for the explanation that clarifies everything. – johnny 5 Jun 16 '18 at 15:29