EntityFramework.Exceptions 3.1.1 - Support for Entity Framework Core 3 and Improved API

In the previous article I introduced EntityFramework.Exceptions, a library which simplifies handling exceptions in Entity Framework Core but the library had one important limitation: In order to use it you had to inherit your custom DbContext from ExceptionProcessorContextBase class. This means that if you wanted to use some other base class for your DbContext you were out of luck. The latest version of the library solves this issue by replacing one of the internal services used by entity framework core with a custom implementation and also adds support for Entity Framework Core 3.1.1

The service that needs to be replaced is IStateManager and is used by the ChangeTracker. The custom implementation of IStateManager interface inherits from the built in StateManager class and overrides SaveChanges and SaveChangesAsync methods. Let’s see how it works:

public abstract class ExceptionProcessorStateManager<T> : StateManager where T : DbException
{
    private static readonly Dictionary<DatabaseError, Func<DbUpdateException, Exception>> ExceptionMapping = new Dictionary<DatabaseError, Func<DbUpdateException, Exception>>
    {
        {DatabaseError.MaxLength, exception => new MaxLengthExceededException("Maximum length exceeded", exception.InnerException) },
        {DatabaseError.UniqueConstraint, exception => new UniqueConstraintException("Unique constraint violation", exception.InnerException) },
        {DatabaseError.CannotInsertNull, exception => new CannotInsertNullException("Cannot insert null", exception.InnerException) },
        {DatabaseError.NumericOverflow, exception => new NumericOverflowException("Numeric overflow", exception.InnerException) },
        {DatabaseError.ReferenceConstraint, exception => new ReferenceConstraintException("Reference constraint violation", exception.InnerException) }
    };

    protected ExceptionProcessorStateManager(StateManagerDependencies dependencies) : base(dependencies)
    {
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        try
        {
            return base.SaveChanges(acceptAllChangesOnSuccess);
        }
        catch (DbUpdateException originalException)
        {
            var exception = GetException(originalException);

            if (exception != null)
            {
                throw exception;
            }

            throw;
        }
    }

    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = new CancellationToken())
    {
        try
        {
            var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
            return result;
        }
        catch (DbUpdateException originalException)
        {
            var exception = GetException(originalException);

            if (exception != null)
            {
                throw exception;
            }

            throw;
        }
    }

    private Exception GetException(DbUpdateException ex)
    {
        if (ex.GetBaseException() is T dbException && GetDatabaseError(dbException) is DatabaseError error && ExceptionMapping.TryGetValue(error, out var ctor))
        {
            return ctor(ex);
        }

        return null;
    }

    protected abstract DatabaseError? GetDatabaseError(T dbException);
}

The abstract ExceptionProcessorStateManager class catches any database exception thrown during SaveChanges call and tries to translate it into one of the supported exception instances. If it succeeds it throws the new exception and if it doesn’t it simply rethrows the original exception. The GetDatabaseError is overriden in database specific projects and returns DatabaseError based on the specific DbException that was thrown:

class SqlServerExceptionProcessorStateManager: ExceptionProcessorStateManager<SqlException>
{
    public SqlServerExceptionProcessorStateManager(StateManagerDependencies dependencies) : base(dependencies)
    {
    }

    private const int ReferenceConstraint = 547;
    private const int CannotInsertNull = 515;
    private const int CannotInsertDuplicateKeyUniqueIndex = 2601;
    private const int CannotInsertDuplicateKeyUniqueConstraint = 2627;
    private const int ArithmeticOverflow = 8115;
    private const int StringOrBinaryDataWouldBeTruncated = 8152;

    protected override DatabaseError? GetDatabaseError(SqlException dbException)
    {
        switch (dbException.Number)
        {
            case ReferenceConstraint:
                return DatabaseError.ReferenceConstraint;
            case CannotInsertNull:
                return DatabaseError.CannotInsertNull;
            case CannotInsertDuplicateKeyUniqueIndex:
            case CannotInsertDuplicateKeyUniqueConstraint:
                return DatabaseError.UniqueConstraint;
            case ArithmeticOverflow:
                return DatabaseError.NumericOverflow;
            case StringOrBinaryDataWouldBeTruncated:
                return DatabaseError.MaxLength;
            default:
                return null;
        }
    }
}

In order to actually replace IStateManager with the custom implementation you need to install either SQL Server, PostgreSQL or MySQL nuget package and call UseExceptionProcessor method of the ExceptionProcessorExtensions from the database specific package:

class DemoContext : DbContext, IDemoContext
{
    public DbSet<Product> Products { get; set; }
    public DbSet<ProductSale> ProductSale { get; set; }

    protected override void OnModelCreating(ModelBuilder builder) 
    { 
        builder.Entity<Product>().Property(b => b.Price).HasColumnType("decimal(5,2)").IsRequired();
        builder.Entity<Product>().Property(b => b.Name).IsRequired().HasMaxLength(15);
        builder.Entity<Product>().HasIndex(u => u.Name).IsUnique(); 
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(@"Data Source=(localdb)\ProjectsV13;Initial Catalog=Test;Integrated Security=True;Connect Timeout=30;")
                      .UseExceptionProcessor();
    }
}

The UseExceptionProcessor method is very simple and all it does is a call to DbContextOptionsBuilder.ReplaceService<TService,TImplementation> method:

public static class ExceptionProcessorExtensions
{
    public static DbContextOptionsBuilder UseExceptionProcessor(this DbContextOptionsBuilder self)
    {
        self.ReplaceService<IStateManager, SqlServerExceptionProcessorStateManager>();
        return self;
    }

    public static DbContextOptionsBuilder<TContext> UseExceptionProcessor<TContext>(this DbContextOptionsBuilder<TContext> self) where TContext : DbContext
    {
        self.ReplaceService<IStateManager, SqlServerExceptionProcessorStateManager>();
        return self;
    }
}

Once you have done it you will start getting database specific exceptions instead of DbUpdateException:

using (var demoContext = new DemoContext())
{
    demoContext.Products.Add(new Product
    {
        Name = "Moon Lamp",
        Price = 1
    });

    demoContext.Products.Add(new Product
    {
        Name = "Moon Lamp",
        Price = 10
    });

    try
    {
        demoContext.SaveChanges();
    }
    catch (UniqueConstraintException e)
    {
        //Unique index was violated. Show corresponding error message to user.
    }
}

The full source code of the library is available on GitHub: EntityFramework.Exceptions If you have questions or suggestions feel free to leave a comment, create an issue and star the repository.

Avatar
Giorgi Dalakishvili
World-Class Software Engineer

Related