refactor: simplify data connections from many-to-many site assignment to direct site ownership

Replace SiteDataConnectionAssignment join table with a direct SiteId FK on DataConnection,
simplifying the data model, repositories, UI, CLI, and deployment service.
This commit is contained in:
Joseph Doherty
2026-03-21 21:07:10 -04:00
parent cd6efeea90
commit 970d0a5cb3
25 changed files with 1543 additions and 490 deletions

View File

@@ -46,26 +46,11 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnecti
builder.Property(d => d.Configuration)
.HasMaxLength(4000);
builder.HasIndex(d => d.Name).IsUnique();
}
}
public class SiteDataConnectionAssignmentConfiguration : IEntityTypeConfiguration<SiteDataConnectionAssignment>
{
public void Configure(EntityTypeBuilder<SiteDataConnectionAssignment> builder)
{
builder.HasKey(a => a.Id);
builder.HasOne<Site>()
.WithMany()
.HasForeignKey(a => a.SiteId)
.OnDelete(DeleteBehavior.Cascade);
.HasForeignKey(d => d.SiteId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<DataConnection>()
.WithMany()
.HasForeignKey(a => a.DataConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => new { a.SiteId, a.DataConnectionId }).IsUnique();
builder.HasIndex(d => new { d.SiteId, d.Name }).IsUnique();
}
}

View File

@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddSiteIdToDataConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Step 1: Drop old unique index on Name (allows duplicate names across sites)
migrationBuilder.DropIndex(
name: "IX_DataConnections_Name",
table: "DataConnections");
// Step 2: Add nullable SiteId column
migrationBuilder.AddColumn<int>(
name: "SiteId",
table: "DataConnections",
type: "int",
nullable: true);
// Step 3: Migrate data from SiteDataConnectionAssignments
migrationBuilder.Sql(@"
-- Phase A: Assign the first site to each existing DataConnection
UPDATE dc
SET dc.SiteId = a.SiteId
FROM DataConnections dc
INNER JOIN (
SELECT DataConnectionId, MIN(SiteId) AS SiteId
FROM SiteDataConnectionAssignments
GROUP BY DataConnectionId
) a ON dc.Id = a.DataConnectionId
WHERE dc.SiteId IS NULL;
-- Phase B: For connections assigned to additional sites, create copies
-- and update InstanceConnectionBindings to point to the new copy
DECLARE @AssignSiteId INT, @AssignConnId INT, @NewConnId INT;
DECLARE @OrigName NVARCHAR(200), @OrigProtocol NVARCHAR(50), @OrigConfig NVARCHAR(4000);
DECLARE assignment_cursor CURSOR FOR
SELECT a.SiteId, a.DataConnectionId
FROM SiteDataConnectionAssignments a
INNER JOIN DataConnections dc ON a.DataConnectionId = dc.Id
WHERE dc.SiteId <> a.SiteId;
OPEN assignment_cursor;
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
WHILE @@FETCH_STATUS = 0
BEGIN
SELECT @OrigName = Name, @OrigProtocol = Protocol, @OrigConfig = Configuration
FROM DataConnections WHERE Id = @AssignConnId;
INSERT INTO DataConnections (SiteId, Name, Protocol, Configuration)
VALUES (@AssignSiteId, @OrigName, @OrigProtocol, @OrigConfig);
SET @NewConnId = SCOPE_IDENTITY();
-- Update bindings for instances on this site to point to the new connection
UPDATE icb
SET icb.DataConnectionId = @NewConnId
FROM InstanceConnectionBindings icb
INNER JOIN Instances i ON icb.InstanceId = i.Id
WHERE icb.DataConnectionId = @AssignConnId
AND i.SiteId = @AssignSiteId;
FETCH NEXT FROM assignment_cursor INTO @AssignSiteId, @AssignConnId;
END
CLOSE assignment_cursor;
DEALLOCATE assignment_cursor;
-- Phase C: Handle any DataConnections not assigned to any site
-- (assign to the first site as a fallback)
UPDATE dc
SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id)
FROM DataConnections dc
WHERE dc.SiteId IS NULL;
");
// Step 4: Make SiteId non-nullable
migrationBuilder.AlterColumn<int>(
name: "SiteId",
table: "DataConnections",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
// Step 5: Add composite unique index and FK
migrationBuilder.CreateIndex(
name: "IX_DataConnections_SiteId_Name",
table: "DataConnections",
columns: new[] { "SiteId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_DataConnections_Sites_SiteId",
table: "DataConnections",
column: "SiteId",
principalTable: "Sites",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
// Step 6: Drop SiteDataConnectionAssignments table
migrationBuilder.DropTable(
name: "SiteDataConnectionAssignments");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Recreate SiteDataConnectionAssignments table
migrationBuilder.CreateTable(
name: "SiteDataConnectionAssignments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DataConnectionId = table.Column<int>(type: "int", nullable: false),
SiteId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id);
table.ForeignKey(
name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId",
column: x => x.DataConnectionId,
principalTable: "DataConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SiteDataConnectionAssignments_Sites_SiteId",
column: x => x.SiteId,
principalTable: "Sites",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_SiteDataConnectionAssignments_DataConnectionId",
table: "SiteDataConnectionAssignments",
column: "DataConnectionId");
migrationBuilder.CreateIndex(
name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId",
table: "SiteDataConnectionAssignments",
columns: new[] { "SiteId", "DataConnectionId" },
unique: true);
// Migrate data back
migrationBuilder.Sql(@"
INSERT INTO SiteDataConnectionAssignments (SiteId, DataConnectionId)
SELECT SiteId, Id FROM DataConnections;
");
// Remove FK and composite index
migrationBuilder.DropForeignKey(
name: "FK_DataConnections_Sites_SiteId",
table: "DataConnections");
migrationBuilder.DropIndex(
name: "IX_DataConnections_SiteId_Name",
table: "DataConnections");
// Restore unique index on Name
migrationBuilder.CreateIndex(
name: "IX_DataConnections_Name",
table: "DataConnections",
column: "Name",
unique: true);
// Drop SiteId column
migrationBuilder.DropColumn(
name: "SiteId",
table: "DataConnections");
}
}
}

View File

@@ -766,9 +766,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("SiteId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("SiteId", "Name")
.IsUnique();
b.ToTable("DataConnections");
@@ -821,30 +824,6 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("Sites");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DataConnectionId")
.HasColumnType("int");
b.Property<int>("SiteId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("DataConnectionId");
b.HasIndex("SiteId", "DataConnectionId")
.IsUnique();
b.ToTable("SiteDataConnectionAssignments");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b =>
{
b.Property<int>("Id")
@@ -1153,18 +1132,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsRequired();
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b =>
modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null)
.WithMany()
.HasForeignKey("DataConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null)
.WithMany()
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});

View File

@@ -27,17 +27,18 @@ public class CentralUiRepository : ICentralUiRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
return await _context.SiteDataConnectionAssignments
return await _context.DataConnections
.AsNoTracking()
.Where(a => a.SiteId == siteId)
.Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d)
.Where(d => d.SiteId == siteId)
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _context.SiteDataConnectionAssignments
return await _context.DataConnections
.AsNoTracking()
.OrderBy(d => d.Name)
.ToListAsync(cancellationToken);
}

View File

@@ -76,13 +76,8 @@ public class SiteRepository : ISiteRepository
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{
var connectionIds = await _dbContext.SiteDataConnectionAssignments
.Where(a => a.SiteId == siteId)
.Select(a => a.DataConnectionId)
.ToListAsync(cancellationToken);
return await _dbContext.DataConnections
.Where(c => connectionIds.Contains(c.Id))
.Where(c => c.SiteId == siteId)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
}
@@ -107,43 +102,13 @@ public class SiteRepository : ISiteRepository
}
else
{
var stub = new DataConnection("stub", "stub") { Id = id };
var stub = new DataConnection("stub", "stub", 0) { Id = id };
_dbContext.DataConnections.Attach(stub);
_dbContext.DataConnections.Remove(stub);
}
return Task.CompletedTask;
}
// --- Site-Connection Assignments ---
public async Task<SiteDataConnectionAssignment?> GetSiteDataConnectionAssignmentAsync(
int siteId, int dataConnectionId, CancellationToken cancellationToken = default)
{
return await _dbContext.SiteDataConnectionAssignments
.FirstOrDefaultAsync(a => a.SiteId == siteId && a.DataConnectionId == dataConnectionId, cancellationToken);
}
public async Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default)
{
await _dbContext.SiteDataConnectionAssignments.AddAsync(assignment, cancellationToken);
}
public Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default)
{
var entity = _dbContext.SiteDataConnectionAssignments.Local.FirstOrDefault(a => a.Id == id);
if (entity != null)
{
_dbContext.SiteDataConnectionAssignments.Remove(entity);
}
else
{
var stub = new SiteDataConnectionAssignment { Id = id };
_dbContext.SiteDataConnectionAssignments.Attach(stub);
_dbContext.SiteDataConnectionAssignments.Remove(stub);
}
return Task.CompletedTask;
}
// --- Instances (for deletion constraint checks) ---
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)

View File

@@ -35,7 +35,6 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
// Sites
public DbSet<Site> Sites => Set<Site>();
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
public DbSet<SiteDataConnectionAssignment> SiteDataConnectionAssignments => Set<SiteDataConnectionAssignment>();
// Deployment
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();