-
Notifications
You must be signed in to change notification settings - Fork 162
/
Copy pathShardingTenantChangeService.cs
299 lines (255 loc) · 12.9 KB
/
ShardingTenantChangeService.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
// Copyright (c) 2022 Jon P Smith, GitHub: JonPSmith, web: http://www.thereformedprogrammer.net/
// Licensed under MIT license. See License.txt in the project root for license information.
using System.Data;
using AuthPermissions.AdminCode;
using AuthPermissions.AspNetCore.GetDataKeyCode;
using AuthPermissions.AspNetCore.ShardingServices;
using AuthPermissions.BaseCode.CommonCode;
using AuthPermissions.BaseCode.DataLayer.Classes;
using Example6.SingleLevelSharding.EfCoreClasses;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
using TestSupport.SeedDatabase;
namespace Example6.SingleLevelSharding.EfCoreCode;
/// <summary>
/// This is the <see cref="ITenantChangeService"/> service for a single-level tenant with Sharding turned on.
/// This is different to the non-sharding versions, as we have to create the the instance of the application's
/// DbContext because the connection string relies on the <see cref="Tenant.DatabaseInfoName"/> in the tenant -
/// see <see cref="GetShardingSingleDbContext"/> at the end of this class. This also allows the DataKey to be added
/// which removes the need for using the IgnoreQueryFilters method on any queries
/// </summary>
public class ShardingTenantChangeService : ITenantChangeService
{
private readonly DbContextOptions<ShardingSingleDbContext> _options;
private readonly IGetSetShardingEntries _shardingService;
private readonly ILogger _logger;
/// <summary>
/// This allows the tenantId of the deleted tenant to be returned.
/// This is useful if you want to soft delete the data
/// </summary>
public int DeletedTenantId { get; private set; }
public ShardingTenantChangeService(DbContextOptions<ShardingSingleDbContext> options,
IGetSetShardingEntries shardingService, ILogger<ShardingTenantChangeService> logger)
{
_options = options;
_shardingService = shardingService;
_logger = logger;
}
/// <summary>
/// This creates a <see cref="CompanyTenant"/> in the given database
/// </summary>
/// <param name="tenant">The tenant data used to create a new tenant</param>
/// <returns>Returns null if all OK, otherwise the create is rolled back and the return string is shown to the user</returns>
public async Task<string> CreateNewTenantAsync(Tenant tenant)
{
using var context = GetShardingSingleDbContext(tenant.DatabaseInfoName, tenant.GetTenantDataKey());
if (context == null)
return $"There is no connection string with the name {tenant.DatabaseInfoName}.";
var databaseError = await CheckDatabaseAndPossibleMigrate(context, tenant, true);
if (databaseError != null)
return databaseError;
if (tenant.HasOwnDb && context.Companies.IgnoreQueryFilters().Any())
return
$"The tenant's {nameof(Tenant.HasOwnDb)} property is true, but the database contains existing companies";
var newCompanyTenant = new CompanyTenant
{
DataKey = tenant.GetTenantDataKey(),
AuthPTenantId = tenant.TenantId,
CompanyName = tenant.TenantFullName
};
context.Add(newCompanyTenant);
await context.SaveChangesAsync();
return null;
}
public async Task<string> SingleTenantUpdateNameAsync(Tenant tenant)
{
using var context = GetShardingSingleDbContext(tenant.DatabaseInfoName, tenant.GetTenantDataKey());
if (context == null)
return $"There is no connection string with the name {tenant.DatabaseInfoName}.";
var companyTenant = await context.Companies
.SingleOrDefaultAsync(x => x.AuthPTenantId == tenant.TenantId);
if (companyTenant != null)
{
companyTenant.CompanyName = tenant.TenantFullName;
await context.SaveChangesAsync();
}
return null;
}
public async Task<string> SingleTenantDeleteAsync(Tenant tenant)
{
using var context = GetShardingSingleDbContext(tenant.DatabaseInfoName, tenant.GetTenantDataKey());
if (context == null)
return $"There is no connection string with the name {tenant.DatabaseInfoName}.";
//If the database doesn't exist then log it and return
if (!await context.Database.CanConnectAsync())
{
_logger.LogWarning("DeleteTenantData: asked to remove tenant data / database, but no database found. " +
$"Tenant name = {tenant?.TenantFullName ?? "- not available -"}");
return null;
}
await using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
await DeleteTenantData(tenant.GetTenantDataKey(), context, tenant);
DeletedTenantId = tenant.TenantId;
await transaction.CommitAsync();
}
catch (Exception e)
{
_logger.LogError(e, $"Failure when trying to delete the '{tenant.TenantFullName}' tenant.");
return "There was a system-level problem - see logs for more detail";
}
return null;
}
public Task<string> HierarchicalTenantUpdateNameAsync(List<Tenant> tenantsToUpdate)
{
throw new NotImplementedException();
}
public Task<string> HierarchicalTenantDeleteAsync(List<Tenant> tenantsInOrder)
{
throw new NotImplementedException();
}
public Task<string> MoveHierarchicalTenantDataAsync(List<(string oldDataKey, Tenant tenantToMove)> tenantToUpdate)
{
throw new NotImplementedException();
}
/// <summary>
/// This method can be quite complicated. It has to
/// 1. Copy the data from the previous database into the new database
/// 2. Delete the old data
/// These two steps have to be done within a transaction, so that a failure to delete the old data will roll back the copy.
/// </summary>
/// <param name="oldDatabaseInfoName"></param>
/// <param name="oldDataKey"></param>
/// <param name="updatedTenant"></param>
/// <returns></returns>
public async Task<string> MoveToDifferentDatabaseAsync(string oldDatabaseInfoName, string oldDataKey, Tenant updatedTenant)
{
//NOTE: The oldContext and newContext have the correct DataKey so you don't have to use IgnoreQueryFilters.
var oldContext = GetShardingSingleDbContext(oldDatabaseInfoName, oldDataKey);
if (oldContext == null)
return $"There is no connection string with the name {oldDatabaseInfoName}.";
var newContext = GetShardingSingleDbContext(updatedTenant.DatabaseInfoName, updatedTenant.GetTenantDataKey());
if (newContext == null)
return $"There is no connection string with the name {updatedTenant.DatabaseInfoName}.";
var databaseError = await CheckDatabaseAndPossibleMigrate(newContext, updatedTenant, true);
if (databaseError != null)
return databaseError;
await using var transactionNew = await newContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
var invoicesWithLineItems = await oldContext.Invoices.AsNoTracking().Include(x => x.LineItems)
.ToListAsync();
//NOTE: writing the entities to the database will set the DataKey on a non-sharding tenant,
//but if its a sharding tenant then the DataKey won't be changed, BUT if you want the DataKey cleared out see the RetailTenantChangeService.MoveHierarchicalTenantDataAsync to manually set the DataKey
var resetter = new DataResetter(newContext);
//This resets the primary / foreign keys to their default value ready to write into the new database
//This method comes from my EfCore.TestSupport library as was used to store data and add it back.
//see the extract part documentation vai https://github.com/JonPSmith/EfCore.TestSupport/wiki/Seed-from-Production-feature
resetter.ResetKeysEntityAndRelationships(invoicesWithLineItems);
newContext.AddRange(invoicesWithLineItems);
var companyTenant = await oldContext.Companies.AsNoTracking().SingleOrDefaultAsync();
if (companyTenant != null)
{
companyTenant.CompanyTenantId = default;
newContext.Add(companyTenant);
}
await newContext.SaveChangesAsync();
//Now we try to delete the old data
await using var transactionOld = await oldContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
await DeleteTenantData(oldDataKey, oldContext);
await transactionOld.CommitAsync();
}
catch (Exception e)
{
_logger.LogError(e, "Failure when trying to delete the original tenant data after the copy over.");
return "There was a system-level problem - see logs for more detail";
}
await transactionNew.CommitAsync();
}
catch (Exception e)
{
_logger.LogError(e, "Failure when trying to copy the tenant data to the new database.");
return "There was a system-level problem - see logs for more detail";
}
return null;
}
//--------------------------------------------------
//private methods / classes
/// <summary>
/// This check is a database is there
/// </summary>
/// <param name="context">The context for the new database</param>
/// <param name="tenant"></param>
/// <param name="migrateEvenIfNoDb">If using local SQL server, Migrate will create the database.
/// That doesn't work on Azure databases</param>
/// <returns></returns>
private static async Task<string?> CheckDatabaseAndPossibleMigrate(ShardingSingleDbContext context, Tenant tenant,
bool migrateEvenIfNoDb)
{
//Thanks to https://stackoverflow.com/questions/33911316/entity-framework-core-how-to-check-if-database-exists
//There are various options to detect if a database is there - this seems the clearest
if (!await context.Database.CanConnectAsync())
{
//The database doesn't exist
if (migrateEvenIfNoDb)
await context.Database.MigrateAsync();
else
{
return $"The database defined by the connection string '{tenant.DatabaseInfoName}' doesn't exist.";
}
}
else if (!await context.Database.GetService<IRelationalDatabaseCreator>().HasTablesAsync())
//The database exists but needs migrating
await context.Database.MigrateAsync();
return null;
}
private async Task DeleteTenantData(string dataKey, ShardingSingleDbContext context, Tenant? tenant = null)
{
if (tenant?.HasOwnDb == true)
{
//The tenant its own database, then you should drop the database, but that depends on what SQL Server provider you use.
//In this case I can the database because it is on a local SqlServer server.
await context.Database.EnsureDeletedAsync();
return;
}
//else we remove all the data with the DataKey of the tenant
var deleteSalesSql = $"DELETE FROM invoice.{nameof(ShardingSingleDbContext.LineItems)} WHERE DataKey = '{dataKey}'";
await context.Database.ExecuteSqlRawAsync(deleteSalesSql);
var deleteStockSql = $"DELETE FROM invoice.{nameof(ShardingSingleDbContext.Invoices)} WHERE DataKey = '{dataKey}'";
await context.Database.ExecuteSqlRawAsync(deleteStockSql);
var companyTenant = await context.Companies.SingleOrDefaultAsync();
if (companyTenant != null)
{
context.Remove(companyTenant);
await context.SaveChangesAsync();
}
}
/// <summary>
/// This create a <see cref="ShardingSingleDbContext"/> with the correct connection string and DataKey
/// </summary>
/// <param name="databaseDataName"></param>
/// <param name="dataKey"></param>
/// <returns><see cref="ShardingSingleDbContext"/> or null if connectionName wasn't found in the appsetting file</returns>
private ShardingSingleDbContext? GetShardingSingleDbContext(string databaseDataName, string dataKey)
{
var connectionString = _shardingService.FormConnectionString(databaseDataName);
if (connectionString == null)
return null;
return new ShardingSingleDbContext(_options, new StubGetShardingDataFromUser(connectionString, dataKey));
}
private class StubGetShardingDataFromUser : IGetShardingDataFromUser
{
public StubGetShardingDataFromUser(string connectionString, string dataKey)
{
ConnectionString = connectionString;
DataKey = dataKey;
}
public string DataKey { get; }
public string ConnectionString { get; }
}
}