Search code examples
c#entity-framework-coreef-core-3.1

Prevent synchronous API usage with EF Core


How can I prevent synchronous database access with Entity Framework Core? e.g. how can I make sure we are calling ToListAsync() instead of ToList()?

I've been trying to get an exception to throw when unit testing a method which calls the synchronous API. Are there configuration options or some methods we could override to make this work?

I have tried using a DbCommandInterceptor, but none of the interceptor methods are called when testing with an in-memory database.


Solution

  • The solution is to use a command interceptor.

    public class AsyncOnlyInterceptor : DbCommandInterceptor
    {
        public bool AllowSynchronous { get; set; } = false;
    
        public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
        {
            ThrowIfNotAllowed();
            return result;
        }
    
        public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
        {
            ThrowIfNotAllowed();
            return result;
        }
    
        public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
        {
            ThrowIfNotAllowed();
            return result;
        }
    
        private void ThrowIfNotAllowed()
        {
            if (!AllowSynchronous)
            {
                throw new NotAsyncException("Synchronous database access is not allowed. Use the asynchronous EF Core API instead.");
            }
        }
    }
    

    If you're wanting to write some tests for this, you can use a Sqlite in-memory database. The Database.EnsureCreatedAsync() method does use synchronous database access, so you will need an option to enable this for specific cases.

    public partial class MyDbContext : DbContext
    {
        private readonly AsyncOnlyInterceptor _asyncOnlyInterceptor;
    
        public MyDbContext(IOptionsBuilder optionsBuilder)
            : base(optionsBuilder.BuildOptions())
        {
            _asyncOnlyInterceptor = new AsyncOnlyInterceptor();
        }
    
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.AddInterceptors(_asyncOnlyInterceptor);
            base.OnConfiguring(optionsBuilder);
        }
    
        public bool AllowSynchronous
        {
            get => _asyncOnlyInterceptor.AllowSynchronous;
            set => _asyncOnlyInterceptor.AllowSynchronous = value;
        }
    }
    

    Here are some helpers for testing. Ensure you aren't using sequences (modelBuilder.HasSequence) because this is not supported by Sqlite.

    public class InMemoryOptionsBuilder<TContext> : IOptionsBuilder
        where TContext : DbContext
    {
        public DbContextOptions BuildOptions()
        {
            var optionsBuilder = new DbContextOptionsBuilder<TContext>();
            var connection = new SqliteConnection("Filename=:memory:");
            connection.Open();
            optionsBuilder = optionsBuilder.UseSqlite(connection);
            return optionsBuilder.Options;
        }
    }
    
    public class Helpers
    {
        public static async Task<MyDbContext> BuildTestDbContextAsync()
        {
            var optionBuilder = new InMemoryOptionsBuilder<MyDbContext>();
            var context = new MyDbContext(optionBuilder)
            {
                AllowSynchronous = true
            };
            await context.Database.EnsureCreatedAsync();
            context.AllowSynchronous = false;
            return context;
        }
    }