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

@@ -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();
});