Dynamically changing schema in Entity Framework Core

亡梦爱人 提交于 2019-11-27 21:28:12

Did you already use EntityTypeConfiguration in EF6?

I think the solution would be use mapping for entities on OnModelCreating method in DbContext class, something like this:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal;
using Microsoft.Extensions.Options;

namespace AdventureWorksAPI.Models
{
    public class AdventureWorksDbContext : Microsoft.EntityFrameworkCore.DbContext
    {
        public AdventureWorksDbContext(IOptions<AppSettings> appSettings)
        {
            ConnectionString = appSettings.Value.ConnectionString;
        }

        public String ConnectionString { get; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(ConnectionString);

            // this block forces map method invoke for each instance
            var builder = new ModelBuilder(new CoreConventionSetBuilder().CreateConventionSet());

            OnModelCreating(builder);

            optionsBuilder.UseModel(builder.Model);
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.MapProduct();

            base.OnModelCreating(modelBuilder);
        }
    }
}

The code on OnConfiguring method forces the execution of MapProduct on each instance creation for DbContext class.

Definition of MapProduct method:

using System;
using Microsoft.EntityFrameworkCore;

namespace AdventureWorksAPI.Models
{
    public static class ProductMap
    {
        public static ModelBuilder MapProduct(this ModelBuilder modelBuilder, String schema)
        {
            var entity = modelBuilder.Entity<Product>();

            entity.ToTable("Product", schema);

            entity.HasKey(p => new { p.ProductID });

            entity.Property(p => p.ProductID).UseSqlServerIdentityColumn();

            return modelBuilder;
        }
    }
}

As you can see above, there is a line to set schema and name for table, you can send schema name for one constructor in DbContext or something like that.

Please don't use magic strings, you can create a class with all available schemas, for example:

using System;

public class Schemas
{
    public const String HumanResources = "HumanResources";
    public const String Production = "Production";
    public const String Sales = "Production";
}

For create your DbContext with specific schema you can write this:

var humanResourcesDbContext = new AdventureWorksDbContext(Schemas.HumanResources);

var productionDbContext = new AdventureWorksDbContext(Schemas.Production);

Obviously you should to set schema name according schema's name parameter's value:

entity.ToTable("Product", schemaName);

There are a couple ways to do this:

  • Build the model externally and pass it in via DbContextOptionsBuilder.UseModel()
  • Replace the IModelCacheKeyFactory service with one that takes the schema into account

Sorry everybody, I should've posted my solution before, but for some reason I didn't, so here it is.

BUT

Keep in mind that anything could be wrong with the solution since it neither hasn't been reviewed by anybody nor production-proved, probably I'll get some feedback here.

In the project I used ASP .NET Core 1


About my db structure. I have 2 contexts. The first one contains information about users (including the db scheme they should address), the second one contains user-specific data.

In Startup.cs I add both contexts

public void ConfigureServices(IServiceCollection 
    services.AddEntityFrameworkNpgsql()
        .AddDbContext<SharedDbContext>(options =>
            options.UseNpgsql(Configuration["MasterConnection"]))
        .AddDbContext<DomainDbContext>((serviceProvider, options) => 
            options.UseNpgsql(Configuration["MasterConnection"])
                .UseInternalServiceProvider(serviceProvider));
...
    services.Replace(ServiceDescriptor.Singleton<IModelCacheKeyFactory, MultiTenantModelCacheKeyFactory>());
    services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

Notice UseInternalServiceProvider part, it was suggested by Nero Sule with the following explanation

At the very end of EFC 1 release cycle, the EF team decided to remove EF's services from the default service collection (AddEntityFramework().AddDbContext()), which means that the services are resolved using EF's own service provider rather than the application service provider.

To force EF to use your application's service provider instead, you need to first add EF's services together with the data provider to your service collection, and then configure DBContext to use internal service provider

Now we need MultiTenantModelCacheKeyFactory

public class MultiTenantModelCacheKeyFactory : ModelCacheKeyFactory {
    private string _schemaName;
    public override object Create(DbContext context) {
        var dataContext = context as DomainDbContext;
        if(dataContext != null) {
            _schemaName = dataContext.SchemaName;
        }
        return new MultiTenantModelCacheKey(_schemaName, context);
    }
}

where DomainDbContext is the context with user-specific data

public class MultiTenantModelCacheKey : ModelCacheKey {
    private readonly string _schemaName;
    public MultiTenantModelCacheKey(string schemaName, DbContext context) : base(context) {
        _schemaName = schemaName;
    }
    public override int GetHashCode() {
        return _schemaName.GetHashCode();
    }
}

Also we have to slightly change the context itself to make it schema-aware:

public class DomainDbContext : IdentityDbContext<ApplicationUser> {
    public readonly string SchemaName;
    public DbSet<Foo> Foos{ get; set; }

    public DomainDbContext(ICompanyProvider companyProvider, DbContextOptions<DomainDbContext> options)
        : base(options) {
        SchemaName = companyProvider.GetSchemaName();
    }
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        modelBuilder.HasDefaultSchema(SchemaName);
        base.OnModelCreating(modelBuilder);
    }
}

and the shared context is strictly bound to shared schema:

public class SharedDbContext : IdentityDbContext<ApplicationUser> {
    private const string SharedSchemaName = "shared";
    public DbSet<Foo> Foos{ get; set; }
    public SharedDbContext(DbContextOptions<SharedDbContext> options)
        : base(options) {}
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        modelBuilder.HasDefaultSchema(SharedSchemaName);
        base.OnModelCreating(modelBuilder);
    }
}

ICompanyProvider is responsible for getting users schema name. And yes, I know how far from the perfect code it is.

public interface ICompanyProvider {
    string GetSchemaName();
}

public class CompanyProvider : ICompanyProvider {
    private readonly SharedDbContext _context;
    private readonly IHttpContextAccessor _accesor;
    private readonly UserManager<ApplicationUser> _userManager;

    public CompanyProvider(SharedDbContext context, IHttpContextAccessor accesor, UserManager<ApplicationUser> userManager) {
        _context = context;
        _accesor = accesor;
        _userManager = userManager;
    }
    public string GetSchemaName() {
        Task<ApplicationUser> getUserTask = null;
        Task.Run(() => {
            getUserTask = _userManager.GetUserAsync(_accesor.HttpContext?.User);
        }).Wait();
        var user = getUserTask.Result;
        if(user == null) {
            return "shared";
        }
        return _context.Companies.Single(c => c.Id == user.CompanyId).SchemaName;
    }
}

And if I haven't missed anything, that's it. Now in every request by an authenticated user the proper context will be used.

I hope it helps.

I find this blog might be useful for you. Perfect !:)

https://romiller.com/2011/05/23/ef-4-1-multi-tenant-with-code-first/

This blog is based on ef4, I'm not sure whether it will work fine with ef core.

public class ContactContext : DbContext
{
    private ContactContext(DbConnection connection, DbCompiledModel model)
        : base(connection, model, contextOwnsConnection: false)
    { }

    public DbSet<Person> People { get; set; }
    public DbSet<ContactInfo> ContactInfo { get; set; }

    private static ConcurrentDictionary<Tuple<string, string>, DbCompiledModel> modelCache
        = new ConcurrentDictionary<Tuple<string, string>, DbCompiledModel>();

    /// <summary>
    /// Creates a context that will access the specified tenant
    /// </summary>
    public static ContactContext Create(string tenantSchema, DbConnection connection)
    {
        var compiledModel = modelCache.GetOrAdd(
            Tuple.Create(connection.ConnectionString, tenantSchema),
            t =>
            {
                var builder = new DbModelBuilder();
                builder.Conventions.Remove<IncludeMetadataConvention>();
                builder.Entity<Person>().ToTable("Person", tenantSchema);
                builder.Entity<ContactInfo>().ToTable("ContactInfo", tenantSchema);

                var model = builder.Build(connection);
                return model.Compile();
            });

        return new ContactContext(connection, compiledModel);
    }

    /// <summary>
    /// Creates the database and/or tables for a new tenant
    /// </summary>
    public static void ProvisionTenant(string tenantSchema, DbConnection connection)
    {
        using (var ctx = Create(tenantSchema, connection))
        {
            if (!ctx.Database.Exists())
            {
                ctx.Database.Create();
            }
            else
            {
                var createScript = ((IObjectContextAdapter)ctx).ObjectContext.CreateDatabaseScript();
                ctx.Database.ExecuteSqlCommand(createScript);
            }
        }
    }
}

The main idea of these codes is to provide a static method to create different DbContext by different schema and cache them with certain identifiers.

maybe I'm a bit late to this answer

my problem was handling different schema with the same structure lets say multi-tenant.

When I tried to create different instances of the same context for the different schemas, Entity frameworks 6 comes to play, catching the first time the dbContext was created then for the following instances they were creates with a different schemas name but onModelCreating were never called meaning that each instance was pointing to the same previously catched Pre-Generated Views, pointing to the first schema.

Then I realized that creating new classes inheriting from myDBContext one for each schema will solve my problem by overcoming entity Framework catching problem creating one new fresh context for each schema, but then comes the problem that we will end with hardcoded schemas, causing another problem in terms of code scalability when we need to add another schema, having to add more classes and recompile and publish a new version of the application.

So I decided to go a little further creating, compiling and adding the classes to the current solution in runtime.

Here is the code

public static MyBaseContext CreateContext(string schema)
{
    MyBaseContext instance = null;
    try
    {
        string code = $@"
            namespace MyNamespace
            {{
                using System.Collections.Generic;
                using System.Data.Entity;

                public partial class {schema}Context : MyBaseContext
                {{
                    public {schema}Context(string SCHEMA) : base(SCHEMA)
                    {{
                    }}

                    protected override void OnModelCreating(DbModelBuilder modelBuilder)
                    {{
                        base.OnModelCreating(modelBuilder);
                    }}
                }}
            }}
        ";

        CompilerParameters dynamicParams = new CompilerParameters();

        Assembly currentAssembly = Assembly.GetExecutingAssembly();
        dynamicParams.ReferencedAssemblies.Add(currentAssembly.Location);   // Reference the current assembly from within dynamic one
                                                                            // Dependent Assemblies of the above will also be needed
        dynamicParams.ReferencedAssemblies.AddRange(
            (from holdAssembly in currentAssembly.GetReferencedAssemblies()
             select Assembly.ReflectionOnlyLoad(holdAssembly.FullName).Location).ToArray());

        // Everything below here is unchanged from the previous
        CodeDomProvider dynamicLoad = CodeDomProvider.CreateProvider("C#");
        CompilerResults dynamicResults = dynamicLoad.CompileAssemblyFromSource(dynamicParams, code);

        if (!dynamicResults.Errors.HasErrors)
        {
            Type myDynamicType = dynamicResults.CompiledAssembly.GetType($"MyNamespace.{schema}Context");
            Object[] args = { schema };
            instance = (MyBaseContext)Activator.CreateInstance(myDynamicType, args);
        }
        else
        {
            Console.WriteLine("Failed to load dynamic assembly" + dynamicResults.Errors[0].ErrorText);
        }
    }
    catch (Exception ex)
    {
        string message = ex.Message;
    }
    return instance;
}

I hope this help someone to save some time.

bubi

You can use Table attribute on the fixed schema tables.

You can't use attribute on schema changing tables and you need to configure that via ToTable fluent API.
If you disable the model cache (or you write your own cache), the schema can change on every request so on the context creation (every time) you can to specify the schema.

This is the base idea

class MyContext : DbContext
{
    public string Schema { get; private set; }

    public MyContext(string schema) : base()
    {

    }

    // Your DbSets here
    DbSet<Emp> Emps { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Emp>()
            .ToTable("Emps", Schema);
    }
}

Now, you can have some different ways to determine the schema name before creating the context.
For example you can have your "system tables" on a different context so on every request you retrieve the schema name from the user name using the system tables and than create the working context on the right schema (you can share tables between contexts).
You can have your system tables detached from the context and use ADO .Net to access to them.
Probably there are several other solutions.

You can also have a look here
Multi-Tenant With Code First EF6

and you can google ef multi tenant

EDIT
There is also the problem of the model caching (I forgot about that). You have to disable the model caching or change the behavior of the cache.

Update for MVC Core 2.1

You can create a model from a database with multiple schemas. The system is a bit schema-agnostic in its naming. Same named tables get a "1" appended. "dbo" is the assumed schema so you don't add anything by prefixing a table name with it the PM command

You will have to rename model file names and class names yourself.

In the PM console

Scaffold-DbContext "Data Source=localhost;Initial Catalog=YourDatabase;Integrated Security=True" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models -force -Tables TableA, Schema1.TableA
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!