At work, my team is decoupling our NuGet libraries from Entity Framework to enable consumers to switch to Entity Framework Core. To decide which features should stay / go, we needed to evaluate how compatible Entity Framework Core features were with our abstractions initially built on Entity Framework's APIs.
I investigated query interception. Although this is a heavily used feature internally, I found literally two paragraphs of information about the feature in Entity Framework Core.
This is a summary of what I learned about the feature and is attempting to be the blog post that I wish I would have found during my investigation.
Query interception is the ability to insert logic before a query executes on the database or insert logic immediately after a query executes (and before control returns to the calling code).
There are a variety of real world use cases for this feature:
EF Core exposes a base class DbCommandInterceptor
with hooks into the query "life cycle".
Create a class that extends DbCommandInterceptor
public class TestQueryInterceptor : DbCommandInterceptor
{
...
}
then override the individual life cycle methods you care about:
// runs before a query is executed
public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
...
}
// runs after a query is executed
public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
...
}
NOTE: Most life cycle methods have a synchronous and an asynchronous version. Annoyingly, asynchronous queries only trigger the asynchronous method (and vice-versa), so you must override both when writing an interceptor.
You can add multiple interceptors when configuring your DbContext.
public class SampleDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlite(@"Data Source=Sample.db;")
.AddInterceptors(new SampleInterceptor1(), new SampleInterceptor2());
}
...
}
This is fairly straightforward because most of DbCommand
's properties are settable.
public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
command.CommandText += " OPTION (OPTIMIZE FOR UNKNOWN)";
command.CommandTimeout = 12345;
return result;
}
By returning a new InterceptionResult
created via InterceptionResult<T>.SuppressWithResult()
from a pre-event life cycle method, the command will not be executed.
It is important to note that any other DbCommandInterceptor
s installed will still execute (and can check whether another interceptor has suppressed execution via the HasResult
property on result
).
public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
{
if (this.ShouldSuppressExecution(command))
{
return InterceptionResult.SuppressWithResult<object>(null);
}
return result;
}
It is worth mentioning that an exception thrown in a pre-event life cycle method will technically prevent execution. Do not take advantage of this fact. It is almost always bad design to use exceptions for control flow. Exceptions should be save for exceptional situations.
public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
if (this.IsSomethingWrongWithThisCommand(command, out var reasonSomethingIsWrong))
{
// query will not be executed
throw new InvalidOperationException(reasonSomethingIsWrong);
}
return result;
}
From a post-event life cycle method, you can opt to return a different result.
public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
if (this.ShouldChangeResult(command, out var changedResult))
{
return changedResult;
}
return result;
}
Although you can't catch exceptions, you can respond to them before they are thrown.
public override void CommandFailed(DbCommand command, CommandErrorEventData eventData)
{
// there is a lot of other metadata on `eventData` that you might find useful
this.LogDiagnosticInformation(
eventData.Duration,
eventData.Exception,
command.CommandText);
}
There are 17 methods you can overwrite when implementing DbCommandInterceptor
.
Here is a cheat sheet:
Method | Description |
---|---|
CommandCreating | Before a command is created (NOTE: Everything is a command, so this will intercept all queries) |
CommandCreated | After a command creation but before execution |
CommandFailed[Async] | After a command has failed with an exception during execution |
ReaderExecuting[Async] | Before a "query" command is executed |
ReaderExecuted[Async] | After a "query" command is executed |
NonQueryExecuting[Async] | Before a "non-query" command is executed (NOTE: An example of a non-query are usages of ExecuteSqlRaw ) |
NonQueryExecuted[Async] | After a "non-query" command is executed |
ScalarExecuting[Async] | Before a "scalar" command is executed (NOTE: "scalar" is kind of synonymous with stored procedure) |
ScalarExecuted[Async] | After a "scalar" command is executed |
DataReaderDisposing | After a command is executed |