Related to multi tenant implementation ( #4410) i have a couple of problem with migration.
What I have:
A multi tenant configuration where some tables are shared between tenats (ex: Users) other are tenant based (ex: Invoices).
What I need:
Create the right Migrations and have a way to build script at runtime to create per-tenant tables.
I have not yet completed the process it could be interesting to have all this things managed by the PMC have not found a way.
To have at least a part of what i need i have modified my "model building" method adding a parameter to create a model with only shared tables.
From Create method of IDbContextFactory implementation (method used only by Migrations commands) i use two method one to create the full model and one to create only the shared-tables part
but to switch between one to another i have to call "dnx ef migrations add [name]" two times and comment the first call or the otherone. (bad things)
I create all the Tenant-based migration with name that start with "Tenant_" than, at runtime i create a Script only with "Tanant_*" migrations.
Here is the (partial) bad code... obviusly i don't like the "comennt/uncomment" procedure, is there a better way to do the job?
public class MyContextFactory : IDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext Create()
{
string connection = GetConnectionStringHere();
//return CreateCompleteForTenant("Tenant_Code", connection); <<< MANUALLY REMOVE COMMENT FOR MIGRATIONS
//return CreateSharedTblContext( connection);
}
}
[...]
public class ApplicationDbContext(): IdentityDbContext<ApplicationUser, IdentityRole, string>
private static IModel GetCompiled(string tenantSchema, bool SharedOnly)
{
//https://github.com/aspnet/EntityFramework/issues/3909
var serviceCollection = new ServiceCollection();
serviceCollection.AddEntityFramework().AddSqlServer();
var serviceProvider = serviceCollection.BuildServiceProvider();
var coreConventionSetBuilder = new CoreConventionSetBuilder();
var sqlConventionSetBuilder = new SqlServerConventionSetBuilder(new SqlServerTypeMapper());
var conventionSet = sqlConventionSetBuilder.AddConventions(coreConventionSetBuilder.CreateConventionSet());
System.Diagnostics.Debug.WriteLine("TenantTEST: Compile for tenant " + tenantSchema);
var builder = new ModelBuilder(conventionSet);
//Shared entities here...
builder.Entity<UserCompany>();
(new ApplicationDbContext()).OnModelCreating(builder); //Build Asp.Net Identity model
if (!SharedOnly)
{
//Per-tenant entities here...
builder.Entity<Invoice>().ToTable("Invoices", tenantSchema);
}
return builder.Model;
}
public string CreateScriptForNewTenant(string Tenant)
{
...
}
It's not very pretty, but below is some code that shows one way to achieve this in RC1.
Code removed as I found a better way to do this, see two comments below...
Closing as this provides a way to achieve this at the moment. I've opened https://github.com/aspnet/EntityFramework/issues/4625 to provide a way to achieve this more cleanly.
Hey,
I found an easier way to do this that avoids needing to modify generated migrations...
``` c#
using Microsoft.Data.Entity;
using Microsoft.Data.Entity.Infrastructure;
using Microsoft.Data.Entity.Metadata;
using Microsoft.Data.Entity.Metadata.Conventions.Internal;
using Microsoft.Data.Entity.Migrations;
using Microsoft.Data.Entity.Migrations.Internal;
using Microsoft.Data.Entity.Storage;
using Microsoft.Data.Entity.Storage.Internal;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace Repro.RC1
{
public class Program
{
static void Main(string[] args)
{
var factory = new MyContextFactory();
using (var context = factory.Create("jeff"))
{
context.Blogs.Add(new Blog { Url = "sample.com/cats" });
context.SaveChanges();
}
using (var context = factory.Create("jane"))
{
context.Blogs.Add(new Blog { Url = "sample.com/dogs" });
context.Blogs.Add(new Blog { Url = "sample.com/horses" });
context.SaveChanges();
}
using (var context = factory.Create("jane"))
{
Console.WriteLine("Jane's Blogs");
foreach (var blog in context.Blogs)
{
Console.WriteLine(" - " + blog.Url);
}
}
using (var context = factory.Create("jeff"))
{
Console.WriteLine("Jeff's Blogs");
foreach (var blog in context.Blogs)
{
Console.WriteLine(" - " + blog.Url);
}
}
}
}
public class MyContext : DbContext
{
public MyContext(string tenant, IServiceProvider provider, DbContextOptions<MyContext> options)
: base(provider, options)
{
Tenant = tenant;
}
public string Tenant { get; private set; }
public DbSet<Blog> Blogs { get; set; }
}
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
public int Rating { get; set; }
}
public class MyContextFactory : IDbContextFactory<MyContext>
{
private readonly string _connectionString;
private readonly IServiceProvider _serviceProvider;
private readonly ConcurrentDictionary<string, DbContextOptions<MyContext>> _optionsCache;
public MyContextFactory()
{
_connectionString = @"Server=(localdb)\mssqllocaldb;Database=Sample;Trusted_Connection=True;";
var serviceCollection = new ServiceCollection();
serviceCollection.AddEntityFramework().AddSqlServer();
serviceCollection.AddScoped<SqlServerHistoryRepository, TenantHistoryRepository>();
serviceCollection.AddScoped<SqlServerMigrationsSqlGenerator, TenantMigrationsSqlGenerator>();
_serviceProvider = serviceCollection.BuildServiceProvider();
_optionsCache = new ConcurrentDictionary<string, DbContextOptions<MyContext>>();
}
// Only used by migrations
MyContext IDbContextFactory<MyContext>.Create()
{
return Create(TenantMigrationsSqlGenerator.TENANT_PLACEHOLDER);
}
public MyContext Create(string tenant)
{
var cachedOptions = _optionsCache.GetOrAdd(
tenant,
t =>
{
// Create the options
var optionsBuilder = new DbContextOptionsBuilder<MyContext>();
optionsBuilder.UseModel(GetModel(tenant));
optionsBuilder.UseSqlServer(_connectionString);
var options = optionsBuilder.Options;
// Ensure the schema is up to date
using (var context = new MyContext(tenant, _serviceProvider, options))
{
context.Database.Migrate();
}
return options;
});
return new MyContext(tenant, _serviceProvider, cachedOptions);
}
private static IModel GetModel(string tenant)
{
var coreConventionSetBuilder = new CoreConventionSetBuilder();
var sqlConventionSetBuilder = new SqlServerConventionSetBuilder(new SqlServerTypeMapper());
var conventionSet = sqlConventionSetBuilder.AddConventions(coreConventionSetBuilder.CreateConventionSet());
var builder = new ModelBuilder(conventionSet);
builder.Entity<Blog>()
.ToTable("blogs", schema: tenant);
return builder.Model;
}
}
public class TenantMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator
{
public static readonly string TENANT_PLACEHOLDER = "tenant_placeholder";
public TenantMigrationsSqlGenerator(DbContext context, IRelationalCommandBuilderFactory commandBuilderFactory, ISqlGenerator sqlGenerator, IRelationalTypeMapper typeMapper, IRelationalAnnotationProvider annotations)
: base(commandBuilderFactory, GetSqlGenerator(context, sqlGenerator), typeMapper, annotations)
{ }
private static ISqlGenerator GetSqlGenerator(DbContext context, ISqlGenerator generator)
{
if (context is MyContext)
{
var tenant = ((MyContext)context).Tenant;
return new TenantSqlGenerator(tenant);
}
return generator;
}
private class TenantSqlGenerator : SqlServerSqlGenerator
{
private readonly string _tenant;
public TenantSqlGenerator(string tenant)
{
_tenant = tenant;
}
public override string EscapeIdentifier(string identifier)
{
if (identifier == TENANT_PLACEHOLDER)
{
identifier = _tenant;
}
return base.EscapeIdentifier(identifier);
}
}
}
public class TenantHistoryRepository : SqlServerHistoryRepository
{
private readonly string _tenantPrefix;
public TenantHistoryRepository(DbContext context, IDatabaseCreator databaseCreator, ISqlCommandBuilder sqlCommandBuilder, ISqlServerConnection connection, IDbContextOptions options, IMigrationsModelDiffer modelDiffer, IMigrationsSqlGenerator migrationsSqlGenerator, IRelationalAnnotationProvider annotations, ISqlGenerator sqlGenerator)
: base(databaseCreator, sqlCommandBuilder, connection, options, modelDiffer, migrationsSqlGenerator, annotations, sqlGenerator)
{
if (context is MyContext)
{
var tenant = ((MyContext)context).Tenant;
_tenantPrefix = $"[{tenant}]";
}
}
public override IReadOnlyList<HistoryRow> GetAppliedMigrations()
{
// Filter applied migrations to ones with the correct tenant prefix
if (_tenantPrefix != null)
{
return base.GetAppliedMigrations()
.Where(r => r.MigrationId.StartsWith(_tenantPrefix))
.Select(r => new HistoryRow(r.MigrationId.Substring(_tenantPrefix.Length), r.ProductVersion))
.ToList();
}
return base.GetAppliedMigrations();
}
public override string GetInsertScript(HistoryRow row)
{
// Add tenant prefix to any new rows
if (_tenantPrefix != null)
{
row = new HistoryRow(_tenantPrefix + row.MigrationId, row.MigrationId);
}
return base.GetInsertScript(row);
}
}
}
```
Thank you very much!
It seems to be perfect! Now I have to try this code.
thank you.
Here are some minor bugs in the sample code @rowanmiller provided above.
1) It will generate the following SQL when ensuring that the tenant schema exists:
IF SCHEMA_ID(N'tenant_placeholder') IS NULL EXEC(N'CREATE SCHEMA [jeff]');
go
To generate the correct SQL, I also had to override GenerateLiteralValue in TenantMigrationsSqlGenerator as follows:
protected override string GenerateLiteralValue(string value, bool unicode = true)
{
if (value == TENANT_PLACEHOLDER)
{
value = _tenant;
}
return base.GenerateLiteralValue(value, unicode);
}
2) There is a small bug in the sample code row = new HistoryRow(_tenantPrefix + row.MigrationId, row.MigrationId);should be row = new HistoryRow(_tenantPrefix + row.MigrationId, row.ProductVersion);
ISqlGenerator is ISqlGenerationHelper (in RC2)
and
SqlServerSqlGenerator is SqlServerSqlGenerationHelper (in RC2).
But how to use this code with RC2?
I am getting a System.InvalidOperationException: Unable to resolve service for type 'Microsoft.EntityFrameworkCore.DbContext' while attempting to activate 'MultiTenant.Demo.TenantHistoryRepository'.
`namespace MultiTenant.Demo
{
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Internal;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Internal;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.Internal;
using Microsoft.Extensions.DependencyInjection;
[UsedImplicitly]
internal class Program
{
[UsedImplicitly]
private static void Main()
{
var factory = new MyContextFactory();
using (var context = factory.Create("jeff"))
{
context.Blogs.Add(new Blog { Url = "sample.com/cats" });
context.SaveChanges();
}
using (var context = factory.Create("jane"))
{
context.Blogs.Add(new Blog { Url = "sample.com/dogs" });
context.Blogs.Add(new Blog { Url = "sample.com/horses" });
context.SaveChanges();
}
using (var context = factory.Create("jane"))
{
Console.WriteLine("Jane's Blogs");
foreach (var blog in context.Blogs)
{
Console.WriteLine(" - " + blog.Url);
}
}
using (var context = factory.Create("jeff"))
{
Console.WriteLine("Jeff's Blogs");
foreach (var blog in context.Blogs)
{
Console.WriteLine(" - " + blog.Url);
}
}
}
}
public class MyContext : DbContext
{
public MyContext()
{
}
public MyContext(string tenant, DbContextOptions<MyContext> options)
: base(options)
{
this.Tenant = tenant;
}
public string Tenant { get; }
public DbSet<Blog> Blogs { get; [UsedImplicitly] set; }
}
public class Blog
{
[UsedImplicitly]
public int BlogId { get; set; }
public string Url { get; set; }
[UsedImplicitly]
public int Rating { get; set; }
}
public class MyContextFactory : IDbContextFactory<MyContext>
{
private readonly string connectionString;
private readonly IServiceProvider serviceProvider;
private readonly ConcurrentDictionary<string, DbContextOptions<MyContext>> optionsCache;
public MyContextFactory()
{
this.connectionString = @"Server=(localdb)\mssqllocaldb;Database=Sample;Trusted_Connection=True;";
var serviceCollection = new ServiceCollection();
serviceCollection.AddEntityFrameworkSqlServer();
serviceCollection.AddScoped<SqlServerHistoryRepository, TenantHistoryRepository>();
serviceCollection.AddScoped<SqlServerMigrationsSqlGenerator, TenantMigrationsSqlGenerator>();
this.serviceProvider = serviceCollection.BuildServiceProvider();
this.optionsCache = new ConcurrentDictionary<string, DbContextOptions<MyContext>>();
}
public MyContext Create(DbContextFactoryOptions options)
{
return this.Create(TenantMigrationsSqlGenerator.TenantPlaceholder);
}
public MyContext Create(string tenant)
{
var cachedOptions = this.optionsCache.GetOrAdd(
tenant,
t =>
{
// Create the options
var optionsBuilder = new DbContextOptionsBuilder<MyContext>();
optionsBuilder.UseModel(GetModel(tenant));
optionsBuilder.UseSqlServer(this.connectionString);
optionsBuilder.UseInternalServiceProvider(this.serviceProvider);
var options = optionsBuilder.Options;
// Ensure the schema is up to date
using (var context = new MyContext(tenant, options))
{
context.Database.Migrate();
}
return options;
});
return new MyContext(tenant, cachedOptions);
}
private static IModel GetModel(string tenant)
{
var coreConventionSetBuilder = new CoreConventionSetBuilder();
var sqlConventionSetBuilder = new SqlServerConventionSetBuilder(new SqlServerTypeMapper(), null, null);
var conventionSet = sqlConventionSetBuilder.AddConventions(coreConventionSetBuilder.CreateConventionSet());
var builder = new ModelBuilder(conventionSet);
builder.Entity<Blog>()
.ToTable("blogs", schema: tenant);
return builder.Model;
}
}
[UsedImplicitly]
public class TenantMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator
{
public static readonly string TenantPlaceholder = "tenant_placeholder";
public TenantMigrationsSqlGenerator(DbContext context, [NotNull] IRelationalCommandBuilderFactory commandBuilderFactory, [NotNull] ISqlGenerationHelper sqlGenerationHelper, [NotNull] IRelationalTypeMapper typeMapper, [NotNull] IRelationalAnnotationProvider annotations)
: base(commandBuilderFactory, GetSqlGenerator(context, sqlGenerationHelper), typeMapper, annotations)
{
}
private static ISqlGenerationHelper GetSqlGenerator(DbContext context, ISqlGenerationHelper generator)
{
var myContext = context as MyContext;
if (myContext == null) return generator;
var tenant = myContext.Tenant;
return new TenantSqlGenerator(tenant);
}
private class TenantSqlGenerator : SqlServerSqlGenerationHelper
{
private readonly string tenant;
public TenantSqlGenerator(string tenant)
{
this.tenant = tenant;
}
public override string EscapeIdentifier(string identifier)
{
if (identifier == TenantPlaceholder)
{
identifier = this.tenant;
}
return base.EscapeIdentifier(identifier);
}
}
}
[UsedImplicitly]
public class TenantHistoryRepository : SqlServerHistoryRepository
{
private readonly string tenantPrefix;
public TenantHistoryRepository(DbContext context, [NotNull] IDatabaseCreator databaseCreator, [NotNull] IRawSqlCommandBuilder rawSqlCommandBuilder, [NotNull] ISqlServerConnection connection, [NotNull] IDbContextOptions options, [NotNull] IMigrationsModelDiffer modelDiffer, [NotNull] IMigrationsSqlGenerator migrationsSqlGenerator, [NotNull] IRelationalAnnotationProvider annotations, [NotNull] ISqlGenerationHelper sqlGenerationHelper) : base(databaseCreator, rawSqlCommandBuilder, connection, options, modelDiffer, migrationsSqlGenerator, annotations, sqlGenerationHelper)
{
var myContext = context as MyContext;
if (myContext == null) return;
var tenant = myContext.Tenant;
this.tenantPrefix = $"[{tenant}]";
}
public override IReadOnlyList<HistoryRow> GetAppliedMigrations()
{
// Filter applied migrations to ones with the correct tenant prefix
if (this.tenantPrefix != null)
{
return base.GetAppliedMigrations()
.Where(r => r.MigrationId.StartsWith(this.tenantPrefix))
.Select(r => new HistoryRow(r.MigrationId.Substring(this.tenantPrefix.Length), r.ProductVersion))
.ToList();
}
return base.GetAppliedMigrations();
}
public override string GetInsertScript(HistoryRow row)
{
// Add tenant prefix to any new rows
if (this.tenantPrefix != null)
{
row = new HistoryRow(this.tenantPrefix + row.MigrationId, row.ProductVersion);
}
return base.GetInsertScript(row);
}
}
}`
@boutquin You can let your TenantHistoryRepository depend on ICurrentDbContext instead of DbContext and then access your context from CurrentDbContext.Context.
Thanks for this great stuff @rowanmiller & @rmja!
IDbContextFactory appears to be obsolete.
Is there any documentation on how to have the databases dynamically generated per tenant (in my scenario each tenant has a separate DB).
Related https://github.com/aspnet/EntityFramework.Docs/issues/777.
Thank you!
Hi @rowanmiller and @rmja !
Thanks for the great examples provided in this topic.
I've got a question: given all the changes between the EF Core version used here and current 3.1 version, how should be this code adapted (assuming it's possible)?
For example:
Thank in advance for any help.
Most helpful comment
Hey,
I found an easier way to do this that avoids needing to modify generated migrations...
``` c#
using Microsoft.Data.Entity;
using Microsoft.Data.Entity.Infrastructure;
using Microsoft.Data.Entity.Metadata;
using Microsoft.Data.Entity.Metadata.Conventions.Internal;
using Microsoft.Data.Entity.Migrations;
using Microsoft.Data.Entity.Migrations.Internal;
using Microsoft.Data.Entity.Storage;
using Microsoft.Data.Entity.Storage.Internal;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace Repro.RC1
{
public class Program
{
static void Main(string[] args)
{
var factory = new MyContextFactory();
}
```