How to turn off ALL conventions in Entity Framework Core 5

Viewed 608

I want to turn off ALL (or at least most of) conventions in Entity Framework Core (and I am talking about EF Core 5 or above) and then build the whole model "by hands".

One may wonder why.

Here is why: I have a task to migrate several large legacy databases from Entity Framework 6 (EF) to Entity Framework Core 5 (EFC). This involves many hundreds of tables and several databases. Some of these databases are created using a Code First approach and some are just third party databases, which we need to query and update from C# code. For the latter databases we must match their schema exactly.

Due to the size of the problem both EF and EFC flavors of the code must coexist for, let's say, several months. This can be easily achieved by using conditional compilation (see below).

Most likely anything that is not supported or is inconveniently supported in EFC in comparison to EF (or was "hacked" into EF models), like spatial indexes, multi-column KeyAttribute PKs, multi-column ForeignKeyAttribute FKs, self-referencing multiple times tables, multiple indexes defined on the same columns (some are filters and some are just regular indexes), and so on and so forth is there.

That's fine. I can easily deal with EFC inability to deal with that by "overriding" the attributes using conditional compilation, e.g.

#if EFCORE
using Key = MyKeyAttribute;
using Column = MyColumnAttribute;
using Index = MyIndexAttribute;
using ForeignKey = MyForeignKeyAttribute;
#endif

then for each MyProject.csproj create a MyProject_EFC.csproj where EFCORE is defined, then use Reflection to "collect" all these custom attributes, and then use EFC Fluent API to configure all that stuff that EFC can't do. So, the legacy (EF) code will still see the originals, e.g. KeyAttribute and then follow the EF route, whereas the EFC code won't see the attributes because they were redefined. And so, it won't complain. I already have all that code, it works and, perhaps, I will put it here or in GitHub at some point, but not today.

What drives me nuts is that no matter what I do, EFC manages to "sneak in" shadow properties and similar crappy things. This gets to the point that I really want to turn off ALL EFC conventions and build the whole model by hands. After all, I am already doing that, like for the 90% of the model. I would rather want EFC throw (with a meaningful error message) than silently do anything that I don't expect it to do.

Following the advice of @IvanStoev here is what I currently have:

public static IModel CreateModel<TContext, TContextInfo>(Action<ModelBuilder, TContextInfo>? modifier = null)
    where TContext : DbContext, ISwyfftDbContext
    where TContextInfo : ContextInfo<TContext>, new()
{
    var contextInfo = new TContextInfo();
    var modelBuilder = new ModelBuilder();

    modelBuilder
        .HasKeys<TContext, TContextInfo>(contextInfo)
        .HasColumnNames<TContext, TContextInfo>(contextInfo)
        .ToTables<TContext, TContextInfo>(contextInfo)
        .DisableCascadeDeletes()
        .HasDefaultValues<TContext, TContextInfo>(contextInfo)
        .HasComputedColumns<TContext, TContextInfo>(contextInfo)
        .HasForeignKeys<TContext, TContextInfo>(contextInfo)
        .HasDatabaseIndexes<TContext, TContextInfo>(contextInfo);

    modifier?.Invoke(modelBuilder, contextInfo);
    var model = modelBuilder.FinalizeRelationalModel();
    return model;
}

private static IModel FinalizeRelationalModel(this ModelBuilder modelBuilder)
{
    var model = modelBuilder.Model;
    var conventionModel = model as IConventionModel;
    var databaseModel = new RelationalModel(model);
    conventionModel.SetAnnotation(RelationalAnnotationNames.RelationalModel, databaseModel);
    return modelBuilder.FinalizeModel();
}

where HasKeys, HasColumnNames, etc. are extension methods that I wrote [earlier] to keep using multi-column PKs, Fs, etc., which are not supported by EFC and conventionModel.SetAnnotation(RelationalAnnotationNames.RelationalModel, databaseModel) is mandatory as otherwise the model is not created and the code fails with NRE.

So, when I stick in this CreateModel into DbContextOptions:

public static DbContextOptions<TContext> GetDbContextOptions(string connectionString, Func<IModel> modelCreator) =>
    new DbContextOptionsBuilder<TContext>()
        .UseModel(modelCreator())
        .UseSqlServer(connectionString, x => x.UseNetTopologySuite())
        .Options;

and create a migration by running e.g. Add-Migration Initial then ModelSnapshot finally comes out correct with no garbage shadow properties, and no other crap that EFC with all conventions inserts here or there. However, when I try to query any table, the code fails with:

(InvalidOperationException) Sequence contains no elements; 
Sequence contains no elements (   at System.Linq.ThrowHelper.ThrowNoElementsException()
   at System.Linq.Enumerable.Single[TSource](IEnumerable`1 source)
   at Microsoft.EntityFrameworkCore.Query.SqlExpressions.SelectExpression..ctor(IEntityType entityType, ISqlExpressionFactory sqlExpressionFactory)
   at Microsoft.EntityFrameworkCore.Query.SqlExpressionFactory.Select(IEntityType entityType)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.CreateShapedQueryExpression(IEntityType entityType)
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.VisitExtension(Expression extensionExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.VisitExtension(Expression extensionExpression)
   at System.Linq.Expressions.Expression.Accept(ExpressionVisitor visitor)
   at System.Linq.Expressions.ExpressionVisitor.Visit(Expression node)
   at Microsoft.EntityFrameworkCore.Query.QueryCompilationContext.CreateQueryExecutor[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Storage.Database.CompileQuery[TResult](Expression query, Boolean async)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.CompileQueryCore[TResult](IDatabase database, Expression query, IModel model, Boolean async)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.<>c__DisplayClass9_0`1.<Execute>b__0()
   at Microsoft.EntityFrameworkCore.Query.Internal.CompiledQueryCache.GetOrAddQuery[TResult](Object cacheKey, Func`1 compiler)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression)
   at Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.ToQueryString(IQueryable source)

which means that the RelationalModel is severely incomplete.

Any further ideas will be highly appreciated. Thanks a lot!

1 Answers

It's possible by building the IModel by hand

static IModel CreateModel(/* args */)
{
    var modelBuilder = new ModelBuilder();
    // Build the model using the modelBuilder
    // ...
    return modelBuilder.FinalizeModel();
}

The essential here is the usage of the parameterless ModelBuilder() constructor which

Initializes a new instance of the ModelBuilder class with no conventions

Then you associate it with the context using the UseModel method, for instance inside the target context OnConfiguring override

optionsBuilder.UseModel(CreateModel())

With this approach, OnModelCreating of the target context is not used.

This should achieve what you are asked for. But beware of the warning for the used ModelBuilder constructor:

Warning: conventions are typically needed to build a correct model.

So you must be very careful to map everything explicitly. From the other side, the exact same approach is used internally by EF Core migrations (the generated method BuildTargetModel inside .designer.cs file) to generate model at point where the classes might not exist or could be totally different, so it should be a viable option when used properly.


Update: It turns out that "conventions are typically needed to build a correct model" in the warning really means that conventions (at least some of them) are really mandatory for building correct runtime model, because they are used to perform some actions which control the runtime behaviors.

Most noticeable are RelationalModelConvention which creates the relational model (tables, columns etc.) mappings, and TypeMappingConvention which creates provider data type mappings. Hence these two are mandatory. But who knows, there could be more. And extensions are allowed to add their own.

So before reading further, consider using the standard approach with all conventions. Seriously. Fluent configuration has higher priority (conventions < data annotations < fluent (explicit)), so if you explicitly configure everything, you shouldn't be getting unexpected shadow, discriminator etc. property issues.

Now, if you want to continue the dangerous path, you should create the minimal conventions needed, or even better, remove the not needed conventions which are causing you problems. The public EF Core 5.x way of modifying conventions is to register custom IConventionSetPlugin implementation which has single method

public ConventionSet ModifyConventions (ConventionSet conventionSet);

which allows you to modify (replace, add new, remove) the default conventions, or even return a brand new convention set.

Registering such plugin is not so easy and requires a bunch of plumbing (even though boilerplate) code. But it is preferred because it allows you to remove specific conventions (just note that the convention class can implement several convention related interfaces, so it must be removed from several ConventionSet lists), and also the mandatory convention classes have additional dependencies and use DI container for resolving them, so it's not easy (if not impossible) to create them from outside.

With that being said, here is a sample implementation which removes all conventions, keeping only the ones registered in ModelFinalizingConventions and ModelFinalizedConventions which seems to be essential for building properly functioning runtime model:

using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure
{
    public class CustomConventionSetPlugin : IConventionSetPlugin
    {
        public ConventionSet ModifyConventions(ConventionSet conventionSet)
        {
            conventionSet.EntityTypeAddedConventions.Clear();
            conventionSet.EntityTypeAnnotationChangedConventions.Clear();
            conventionSet.EntityTypeBaseTypeChangedConventions.Clear();
            conventionSet.EntityTypeIgnoredConventions.Clear();
            conventionSet.EntityTypeMemberIgnoredConventions.Clear();
            conventionSet.EntityTypePrimaryKeyChangedConventions.Clear();
            conventionSet.ForeignKeyAddedConventions.Clear();
            conventionSet.ForeignKeyAnnotationChangedConventions.Clear();
            conventionSet.ForeignKeyDependentRequirednessChangedConventions.Clear();
            conventionSet.ForeignKeyRequirednessChangedConventions.Clear();
            conventionSet.ForeignKeyUniquenessChangedConventions.Clear();
            conventionSet.IndexAddedConventions.Clear();
            conventionSet.IndexAnnotationChangedConventions.Clear();
            conventionSet.IndexRemovedConventions.Clear();
            conventionSet.IndexUniquenessChangedConventions.Clear();
            conventionSet.KeyAddedConventions.Clear();
            conventionSet.KeyAnnotationChangedConventions.Clear();
            conventionSet.KeyRemovedConventions.Clear();
            conventionSet.ModelAnnotationChangedConventions.Clear();
            //conventionSet.ModelFinalizedConventions.Clear();
            //conventionSet.ModelFinalizingConventions.Clear();
            conventionSet.ModelInitializedConventions.Clear();
            conventionSet.NavigationAddedConventions.Clear();
            conventionSet.NavigationAnnotationChangedConventions.Clear();
            conventionSet.NavigationRemovedConventions.Clear();
            conventionSet.PropertyAddedConventions.Clear();
            conventionSet.PropertyAnnotationChangedConventions.Clear();
            conventionSet.PropertyFieldChangedConventions.Clear();
            conventionSet.PropertyNullabilityChangedConventions.Clear();
            conventionSet.PropertyRemovedConventions.Clear();
            conventionSet.SkipNavigationAddedConventions.Clear();
            conventionSet.SkipNavigationAnnotationChangedConventions.Clear();
            conventionSet.SkipNavigationForeignKeyChangedConventions.Clear();
            conventionSet.SkipNavigationInverseChangedConventions.Clear();
            conventionSet.SkipNavigationRemovedConventions.Clear();
            return conventionSet;
        }
    }
}

// Boilerplate for regigistering the plugin

namespace Microsoft.EntityFrameworkCore.Infrastructure
{
    public class CustomConventionSetOptionsExtension : IDbContextOptionsExtension
    {
        public CustomConventionSetOptionsExtension() { }
        ExtensionInfo info;
        public DbContextOptionsExtensionInfo Info => info ?? (info = new ExtensionInfo(this));
        public void Validate(IDbContextOptions options) { }
        public void ApplyServices(IServiceCollection services)
            => services.AddSingleton<IConventionSetPlugin, CustomConventionSetPlugin>();
        sealed class ExtensionInfo : DbContextOptionsExtensionInfo
        {
            public ExtensionInfo(CustomConventionSetOptionsExtension extension) : base(extension) { }
            public override bool IsDatabaseProvider => false;
            public override string LogFragment => string.Empty;
            public override void PopulateDebugInfo(IDictionary<string, string> debugInfo) { }
            public override long GetServiceProviderHashCode() => 1234;
        }
    }
}

namespace Microsoft.EntityFrameworkCore
{
    public static partial class CustomDbContextOptionsExtensions
    {
        public static DbContextOptionsBuilder UseCustomConventionSet(this DbContextOptionsBuilder optionsBuilder)
        {
            if (optionsBuilder.Options.FindExtension<CustomConventionSetOptionsExtension>() == null)
                ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(new CustomConventionSetOptionsExtension());
            return optionsBuilder;
        }
    }
}

It provides a convenient Use extension method similar to other extensions, so all you need is to call it during the configuration, e.g. in OnConfiguring override

optionsBuilder.UseCustomConventionSet();

or with your example

public static DbContextOptions<TContext> GetDbContextOptions(string connectionString, Func<IModel> modelCreator) =>
    new DbContextOptionsBuilder<TContext>()
        .UseSqlServer(connectionString, x => x.UseNetTopologySuite())
        .UseCustomConventionSet()
        .Options;

OnConfiguring is preferred though, since this has nothing to do with database provider, and also does not use external model creation (and UseModel) as with the original suggestion - the fluent configuration goes back to OnModelCreating override.

Related