Skip to content
Merged

Dev #2697

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private static WorksheetMappingFieldsDto MapWorksheet(Worksheet worksheet)
.Where(field => IsMappable(field))
.Select(field => new MappingFieldDto
{
Name = string.IsNullOrWhiteSpace(field.Key) ? field.Name : field.Key,
Name = $"{field.Name}.{field.Type}",
Type = ConvertCustomType(field.Type),
IsCustom = true,
Label = $"{field.Label} ({worksheet.Name})"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ namespace Unity.GrantManager.EntityFrameworkCore;
public class EntityFrameworkCoreGrantManagerDbSchemaMigrator
: IGrantManagerDbSchemaMigrator, ITransientDependency
{
/* Migration history was squashed to a single "Initial" migration per context
* (see Migrations/HostMigrations and Migrations/TenantMigrations). Databases that
* were already migrated under the old, now-deleted migration set still carry
* __EFMigrationsHistory rows for those old migration ids, which don't match
* "Initial" and would make Database.MigrateAsync() below try to re-run Initial's
* CreateTable operations against a schema that already has them.
*
* ReconcileMigrationHistoryAsync resets history to just the Initial row *before*
* MigrateAsync() is called, so EF sees it as already applied and skips it. Brand
* new databases (including newly provisioned tenants) have an empty or nonexistent
* history table at this point, so the reconciliation is a no-op and MigrateAsync()
* runs Initial for real to build the schema. Safe to run unconditionally on every
* migrator invocation, forever - after the first run per database, history only
* ever contains the Initial row so the guard clause never fires again.
*/
private const string HostInitialMigrationId = "20260722193713_Initial";
private const string TenantInitialMigrationId = "20260721203242_Initial";
private const string EfCoreProductVersion = "10.0.3";

private readonly IServiceProvider _serviceProvider;
private readonly IStringEncryptionService _encryptionService;

Expand Down Expand Up @@ -89,6 +108,8 @@ public async Task MigrateAsync(Tenant? tenant)
await tenantDb.ExecuteSqlRawAsync(
tenantDb.GetService<IHistoryRepository>().GetCreateIfNotExistsScript());

await ReconcileMigrationHistoryAsync(tenantDb, TenantInitialMigrationId);

// Run migrations as admin against the tenant database
await tenantDb.MigrateAsync();

Expand Down Expand Up @@ -118,13 +139,45 @@ the correct one. */
}
else
{
await _serviceProvider
var hostDb = _serviceProvider
.GetRequiredService<GrantManagerDbContext>()
.Database
.MigrateAsync();
.Database;

// The database itself may not exist yet on a brand new Postgres instance.
// MigrateAsync() would normally create it as its first step, but
// ReconcileMigrationHistoryAsync needs to connect before that, so ensure
// it exists here first (mirrors the tenant path further up).
if (!await hostDb.CanConnectAsync())
{
await hostDb.GetService<IRelationalDatabaseCreator>().CreateAsync();
}

await ReconcileMigrationHistoryAsync(hostDb, HostInitialMigrationId);

await hostDb.MigrateAsync();
}
}

private static async Task ReconcileMigrationHistoryAsync(DatabaseFacade database, string initialMigrationId)
{
// initialMigrationId/EfCoreProductVersion are hardcoded constants above, never
// external or tenant-controlled input, so interpolating them here is safe.
#pragma warning disable EF1002
await database.ExecuteSqlRawAsync($"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '__EFMigrationsHistory') THEN
IF EXISTS (SELECT 1 FROM public."__EFMigrationsHistory" WHERE "MigrationId" <> '{initialMigrationId}') THEN
DELETE FROM public."__EFMigrationsHistory";
INSERT INTO public."__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('{initialMigrationId}', '{EfCoreProductVersion}');
END IF;
END IF;
END $$;
""");
#pragma warning restore EF1002
}

// Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value.
// A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage)
// is also rejected by the Contains('=') check so we surface a meaningful error rather than a
Expand Down
Loading
Loading