Announcing Entity Framework 7 Preview 7: Interceptors!

Arthur Vickers

Jeremy Likness

Entity Framework Core 7 (EF7) Preview 7 has shipped with support for several new interceptors, as well as improvements to existing interceptors. This blog post will cover these changes, but Preview 7 also contains several additional enhancements, including:

See the full list of EF7 Preview 7 changes on GitHub.

Interceptors

EF Core interceptors enable interception, modification, and/or suppression of EF Core operations. This includes low-level database operations such as executing a command, as well as higher-level operations, such as calls to SaveChanges. Interceptors support async operations and the ability to modify or suppress operations, making them more powerful than traditional events, logging, and diagnostics.

Interceptors are registered when configuring a DbContext instance, either in OnConfiguring or AddDbContext. For example:

public class ExampleContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

Code for all the examples in this post is available on GitHub.

New and improved interceptors in EF7

EF7 includes the following enhancements to interceptors:

In addition, EF7 includes new traditional .NET events for:

The following sections show some examples of using these new interception capabilities.

Simple actions on entity creation

The new IMaterializationInterceptor supports interception before and after an entity instance is created, and before and after properties of that instance are initialized. The interceptor can change or replace the entity instance at each point. This allows:

  • Setting unmapped properties or calling methods needed for validation, computed values, or flags
  • Using a factory to create instances
  • Creating a different entity instance than EF would normally create, such as an instance from a cache, or of a proxy type
  • Injecting services into an entity instance

For example, imagine that we want to keep track of the time that an entity was retrieved from the database, perhaps so it can be displayed to a user editing the data. To accomplish this, we first define an interface:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

Using an interface is common with interceptors since it allows the same interceptor to work with many different entity types. For example:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Notice that the [NotMapped] attribute is used to indicate that this property is used only while working with the entity, and should not be persisted to the database.

The interceptor must then implement the appropriate method from IMaterializationInterceptor and set the time retrieved:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

An instance of this interceptor is registered when configuring the DbContext:


public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

๐Ÿ’กTIP This interceptor is stateless, which is common, so a single instance is created and shared between all DbContext instances.

Now, whenever a Customer is queried from the database, the Retrieved property will be set automatically. For example:

using (var context = new CustomerContext())
{
    var customer = context.Customers.Single(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Produces output:

Customer 'Alice' was retrieved at '8/6/2022 7:50:25 PM'

LINQ expression tree interception

EF Core makes use of .NET LINQ queries. This typically involves using the C#, VB, or F# compiler to build an expression tree which is then translated by EF Core into the appropriate SQL. For example, consider a method that returns a page of customers:

List<Customer> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToList();
}

๐Ÿ’กTIP This query uses the EF.Property method to specify the property to sort by. This allows the application to dynamically pass in the property name, easily allowing sorting by any property of the entity type.

This will work fine as long as the property used for sorting always returns a stable ordering. But this may not always be the case. For example, the LINQ query above generates the following on SQLite when ordering by Customer.City:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

Uf there are multiple customers with the same City, then the ordering of this query is not stable. This could lead to missing or duplicate results as the user pages through the data.

A common way to fix this problem is to perform a secondary sorting by primary key. However, rather than manually adding this to every query, we can intercept the query expression tree and add the secondary ordering dynamically whenever an OrderBy is encountered. To facilitate this, we will again use an interface, this time for any entity that has an integer primary key:

public interface IHasIntKey
{
    int Id { get; }
}

This interface is implemented by the entity types of interest:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

We then need an interceptor that implements IQueryExpressionInterceptor:

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression ProcessingQuery(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        methodCallExpression,
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

This probably looks pretty complicated–and it is! Working with expression trees is typically not easy. Let’s look at what’s happening:

  • Fundamentally, the interceptor encapsulates an ExpressionVisitor. The visitor overrides VisitMethodCall, which will be called whenever there is a call to a method in the query expression tree.
  • The visitor checks whether or not this is a call to the Queryable.OrderBy method we are interested in.
  • If it is, then the visitor further checks when the generic method call is for a type that implements our IHasIntKey interface.
  • At this point we know that the method call is of the form OrderBy(e => ...). We extract the lambda expression from this call and get the parameter used in that expression–that is, the e.
  • We now build a new MethodCallExpression using the Expression.Call builder method. In this case, the method being called is ThenBy(e => e.Id). We build this using the parameter extracted above and a property access to the Id property of the IHasIntKey interface.
  • The input into this call is the original OrderBy(e => ...), and so the end result is an expression for OrderBy(e => ...).ThenBy(e => e.Id).
  • This modified expression is returned from the visitor, which means the LINQ query has now been appropriately modified to include a ThenBy call.
  • EF Core continues and compiles this query expression into the appropriate SQL for the database being used.

This interceptor is registered in the same way as we did for the first example. Executing GetPageOfCustomers now generates the following SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

This will now always produce a stable ordering, even if there are multiple customers with the same City.

Phew! That’s a lot of code to make a simple change to a query. And even worse, it might not even work for all queries. It is notoriously difficult write an expression visitor that recognizes all the query shapes it should, and none of the ones it should not. For example, this will likely not work if the ordering is done in a subquery.

This brings us to a critical point about interceptors–always ask yourself if there is an easier way of doing what you want. Interceptors are powerful, but it’s easy to get things wrong. They are, as the saying goes, an easy way to shoot yourself in the foot.

For example, imagine if we instead changed our GetPageOfCustomers method like so:

List<Customer> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToList();
}

In this case the ThenBy is simply added to the query. Yes, it may need to be done separately to every query, but it’s simple, easy to understand, and will always work.

Optimistic concurrency interception

EF Core supports the optimistic concurrency pattern by checking that the number of rows actually affected by an update or delete is the same as the number of rows expected to be affected. This is often coupled with a concurrency token; that is, a column value that will only match its expected value if the row has not been updated since the expected value was read.+

EF signals a violation of optimistic concurrency by throwing a DbUpdateConcurrencyException. In EF7 ISaveChangesInterceptor has new methods ThrowingConcurrencyException and ThrowingConcurrencyExceptionAsync that are called before the DbUpdateConcurrencyException is thrown. These interception points allow the exception to be suppressed, possibly coupled with async database changes to resolve the violation.

For example, if two requests attempt to delete the same entity at almost the same time, then the second delete may fail because the row in the database no longer exists. This may be fine–the end result is that the entity has been deleted anyway. The following interceptor demonstrates how this can be done:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData) eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

There are several things worth noting about this interceptor:

  • Both the synchronous and asynchronous interception methods are implemented. This is important if the application may call either SaveChanges or SaveChangesAsync. However, if all application code is async, then only ThrowingConcurrencyExceptionAsync needs to be implemented. Likewise, if the application never uses synchronous database methods, then only ThrowingConcurrencyException needs to be implemented. This is generally true for all interceptors with sync and async methods. (It might be worthwhile implementing the method your application does not use to throw, just in case some sync/async code creeps in.)
  • The interceptor has access to EntityEntry objects for the entities being saved. In this case, this is used to check whether or not the concurrency violation is happening for a delete operation.
  • If the application is using a relational database provider, then the ConcurrencyExceptionEventData object can be cast to a RelationalConcurrencyExceptionEventData object. This provides additional, relational-specific information about the database operation being performed. In this case, the relation command text is printed to the console.
  • Returning InterceptionResult.Suppress() tells EF Core to suppress the action it was about to take–in this case, throwing the DbUpdateConcurrencyException. This is one of the most powerful features of interceptors to change the behavior if EF Core, rather than just observing what EF Core is doing.

Summary

EF Core 7.0 (EF7) adds several new interceptors and enhances the behavior of already existing interceptors. Interceptors allow application react to what EF is doing in a similar way to events. However, interceptors also allow the behavior of EF to be changed by suppressing or replacing the action that EF was going to perform, or by modifying or replacing the result of that action. Where appropriate, interceptors can perform async database access, allowing complete database commands to be changed by an interceptor.

For more information of EF Core interceptors, see Interceptors in the EF Core documentation.

Also, the EF Team showed some additional in-depth interceptor examples in an episode of the .NET Data Community Standup–available to watch now on YouTube.

Finally, don’t forget that all the code in this post is available on GitHub.

EF7 Prerequisites

  • EF7 targets .NET 6, which means it can be used on .NET 6 (LTS) or .NET 7.
  • EF7 will not run on .NET Framework.

EF7 is the successor to EF Core 6.0, not to be confused with EF6. If you are considering upgrading from EF6, please read our guide to port from EF6 to EF Core.

How to get EF7 previews

EF7 is distributed exclusively as a set of NuGet packages. For example, to add the SQL Server provider to your project, you can use the following command using the dotnet tool:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0-preview.7.22376.2

This following table links to the preview 7 versions of the EF Core packages and describes what they are used for.

Package Purpose
Microsoft.EntityFrameworkCore The main EF Core package that is independent of specific database providers
Microsoft.EntityFrameworkCore.SqlServer Database provider for Microsoft SQL Server and SQL Azure
Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite SQL Server support for spatial types
Microsoft.EntityFrameworkCore.Sqlite Database provider for SQLite that includes the native binary for the database engine
Microsoft.EntityFrameworkCore.Sqlite.Core Database provider for SQLite without a packaged native binary
Microsoft.EntityFrameworkCore.Sqlite.NetTopologySuite SQLite support for spatial types
Microsoft.EntityFrameworkCore.Cosmos Database provider for Azure Cosmos DB
Microsoft.EntityFrameworkCore.InMemory The in-memory database provider
Microsoft.EntityFrameworkCore.Tools EF Core PowerShell commands for the Visual Studio Package Manager Console; use this to integrate tools like scaffolding and migrations with Visual Studio
Microsoft.EntityFrameworkCore.Design Shared design-time components for EF Core tools
Microsoft.EntityFrameworkCore.Proxies Lazy-loading and change-tracking proxies
Microsoft.EntityFrameworkCore.Abstractions Decoupled EF Core abstractions; use this for features like extended data annotations defined by EF Core
Microsoft.EntityFrameworkCore.Relational Shared EF Core components for relational database providers
Microsoft.EntityFrameworkCore.Analyzers C# analyzers for EF Core

We also published the 7.0 preview 7 release of the Microsoft.Data.Sqlite.Core provider for ADO.NET.

Installing the EF7 Command Line Interface (CLI)

Before you can execute EF7 Core migration or scaffolding commands, you’ll have to install the CLI package as either a global or local tool.

To install the preview tool globally, install with:

dotnet tool install --global dotnet-ef --version 7.0.0-preview.7.22376.2 

If you already have the tool installed, you can upgrade it with the following command:

dotnet tool update --global dotnet-ef --version 7.0.0-preview.7.22376.2 

It’s possible to use this new version of the EF7 CLI with projects that use older versions of the EF Core runtime.

Daily builds

EF7 previews are aligned with .NET 7 previews. These previews tend to lag behind the latest work on EF7. Consider using the daily builds instead to get the most up-to-date EF7 features and bug fixes.

As with the previews, the daily builds require .NET 6.

The .NET Data Community Standup

The .NET data team is now live streaming every other Wednesday at 10am Pacific Time, 1pm Eastern Time, or 17:00 UTC. Join the stream to ask questions about the data-related topic of your choice, including the latest preview release.

Documentation and Feedback

The starting point for all EF Core documentation is docs.microsoft.com/ef/.

Please file issues found and any other feedback on the dotnet/efcore GitHub repo.

The following links are provided for easy reference and access.

Thank you from the team

A big thank you from the EF team to everyone who has used and contributed to EF over the years!

Welcome to EF7.