using Microsoft.Data.SqlClient;
namespace SwPackagePromote;
public sealed record PromoteResult(
int DbModelId, int PackageId, bool PackageCreated,
int UnchangedCount, int PromotedCount, int LinkedCount);
///
/// The actual git-style "does the target already have this content" logic — see this project's
/// own .csproj header for the full design. Every DdlScriptId/PackageId is local to whichever
/// Gb5system it's read from; the only thing compared across the two independent installations
/// is CHECKSUM (portable, content-addressed, exactly like a git blob's own hash) and
/// PackageCode/DbModelCode (human-assigned, portable identifiers — the same convention
/// FullBaseSchemaLoader already established for exactly this reason).
///
public static class GovernanceImporter
{
public static async Task PromoteAsync(SqlConnection conn, OfflinePackageBundle bundle, PromoteOptions opts)
{
int dbModelId = await ResolveDbModelIdAsync(conn, opts.DbModelCode);
int branchId = await EnsureScriptBranchAsync(conn, opts.BranchId, opts.BranchCode, dbModelId, opts.CreatedById, opts.TenantId);
int unchanged = 0, promoted = 0;
var currentDdlScriptIds = new List();
foreach (var script in bundle.Scripts.OrderBy(s => s.Sequence))
{
var objectName = script.ObjectName ?? throw new InvalidOperationException("A script in the bundle has no ObjectName.");
var checksum = script.Checksum ?? throw new InvalidOperationException($"Script '{objectName}' has no Checksum.");
var objectType = script.ObjectType;
// Keyed on (ObjectName, ObjectType), not ObjectName alone — a real, live-caught bug
// otherwise: two scripts legitimately sharing one ObjectName (e.g. MCURRENCY's own
// real CREATE TABLE + a separate reference-data seed) would flip-flop each other's
// checksum comparison within the SAME promotion run, never converging to
// "unchanged" even on an exact re-run of identical content.
var tip = await GetCurrentTipAsync(conn, dbModelId, objectName, objectType, opts.TenantId);
if (tip is not null && string.Equals(tip.Value.Checksum, checksum, StringComparison.OrdinalIgnoreCase))
{
// Content already matches the target's own current version — git's own
// "nothing to commit" case. Skip the write entirely; the existing DdlScriptId
// is what the package's own link should point at.
unchanged++;
currentDdlScriptIds.Add(tip.Value.DdlScriptId);
Console.WriteLine($" [{script.Sequence}] {objectName}: unchanged (checksum matches target's current tip) — skipped.");
continue;
}
int newDdlScriptId = await InsertNewScriptVersionAsync(
conn, dbModelId, branchId, objectName, objectType, script.SqlScript, checksum, opts.CreatedById, opts.TenantId);
if (tip is not null)
await ClearCurrentTipAsync(conn, dbModelId, objectName, objectType, opts.CreatedById, opts.TenantId);
await InsertHistoryVersionAsync(
conn, dbModelId, objectName, objectType, newDdlScriptId, tip?.DdlScriptId, checksum, opts.CreatedById, opts.TenantId);
promoted++;
currentDdlScriptIds.Add(newDdlScriptId);
Console.WriteLine(tip is null
? $" [{script.Sequence}] {objectName}: new object — registered as DdlScriptId={newDdlScriptId}."
: $" [{script.Sequence}] {objectName}: changed (was DdlScriptId={tip.Value.DdlScriptId}) — new version DdlScriptId={newDdlScriptId}.");
}
var (packageId, created) = await EnsureUpgradePackageAsync(conn, bundle, dbModelId, opts.SequenceInChain, opts.CreatedById, opts.TenantId);
int linked = await RelinkPackageAsync(conn, packageId, currentDdlScriptIds, opts.TenantId);
return new PromoteResult(dbModelId, packageId, created, unchanged, promoted, linked);
}
private static async Task ResolveDbModelIdAsync(SqlConnection conn, string dbModelCode)
{
await using var cmd = new SqlCommand("SELECT DBMODELID FROM SW.MSWDBMODEL WHERE DBMODELCODE = @Code", conn);
cmd.Parameters.AddWithValue("@Code", dbModelCode);
var result = await cmd.ExecuteScalarAsync()
?? throw new InvalidOperationException(
$"No SW.MSWDBMODEL row with DBMODELCODE='{dbModelCode}' exists on this target — register it first " +
"(this tool deliberately never creates one; which physical server/database a DbModel maps to is a " +
"real operational decision for whoever manages this target's own SqlWorkbench, not something to " +
"infer from a package file).");
return Convert.ToInt32(result);
}
private static async Task EnsureScriptBranchAsync(
SqlConnection conn, int branchId, string branchCode, int dbModelId, int createdById, int tenantId)
{
await using (var check = new SqlCommand(
"SELECT BRANCHID FROM SW.MSWSCRIPTBRANCH WHERE BRANCHID = @Id OR (BRANCHCODE = @Code AND DBMODELID = @DbModelId)", conn))
{
check.Parameters.AddWithValue("@Id", branchId);
check.Parameters.AddWithValue("@Code", branchCode);
check.Parameters.AddWithValue("@DbModelId", dbModelId);
var existing = await check.ExecuteScalarAsync();
if (existing is not null) return Convert.ToInt32(existing);
}
var now = DateTime.UtcNow;
await using var insert = new SqlCommand(@"
INSERT INTO SW.MSWSCRIPTBRANCH
(BRANCHID, DBMODELID, BRANCHCODE, BRANCHNAME, BRANCHTYPE, BRANCHSTATUS, PARENTBRANCHID,
DESCRIPTION, VERSION, STATUS, SORTORDER, CREATEDBYID, CREATEDON, MODIFIEDBYID, MODIFIEDON, SOURCETYPE, TENANTID)
VALUES
(@Id, @DbModelId, @Code, @Code, 0, 0, NULL,
@Desc, 0, 1, 9999, @CreatedById, @Now, @CreatedById, @Now, 5, @TenantId)", conn);
insert.Parameters.AddWithValue("@Id", branchId);
insert.Parameters.AddWithValue("@DbModelId", dbModelId);
insert.Parameters.AddWithValue("@Code", branchCode);
insert.Parameters.AddWithValue("@Desc", "Created by SwPackagePromote");
insert.Parameters.AddWithValue("@CreatedById", createdById);
insert.Parameters.AddWithValue("@Now", now);
insert.Parameters.AddWithValue("@TenantId", tenantId);
await insert.ExecuteNonQueryAsync();
return branchId;
}
private static async Task<(int DdlScriptId, string Checksum)?> GetCurrentTipAsync(
SqlConnection conn, int dbModelId, string objectName, int objectType, int tenantId)
{
await using var cmd = new SqlCommand(@"
SELECT DDLSCRIPTID, CHECKSUM FROM SW.MSWDDLOBJECTHISTORY
WHERE DBMODELID = @DbModelId AND SCHEMANAME = 'dbo' AND OBJECTNAME = @ObjectName
AND OBJECTTYPE = @ObjectType AND ISCURRENTTIP = 1 AND TENANTID = @TenantId", conn);
cmd.Parameters.AddWithValue("@DbModelId", dbModelId);
cmd.Parameters.AddWithValue("@ObjectName", objectName);
cmd.Parameters.AddWithValue("@ObjectType", objectType);
cmd.Parameters.AddWithValue("@TenantId", tenantId);
await using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync()) return null;
return (reader.GetInt32(0), reader.GetString(1));
}
private static async Task InsertNewScriptVersionAsync(
SqlConnection conn, int dbModelId, int branchId, string objectName, int objectType, string sqlScript, string checksum,
int createdById, int tenantId)
{
int ddlScriptId = await AutoNumber.ReserveAsync(conn, "SWDDLSCRIPT", 1);
var now = DateTime.UtcNow;
await using var insert = new SqlCommand(@"
INSERT INTO SW.MSWDDLSCRIPT
(DDLSCRIPTID, DBMODELID, BRANCHID, OBJECTGROUPID, OBJECTTYPE, SCHEMANAME,
OBJECTNAME, SQLSCRIPT, ROLLBACKSQL, CHECKSUM, SCRIPTSTATUS, APPROVALREQUIRED,
APPROVEDBYID, APPROVEDON, SEQUENCE,
VERSION, STATUS, SORTORDER, CREATEDBYID, CREATEDON, MODIFIEDBYID, MODIFIEDON,
SOURCETYPE, TENANTID, CHANGEREQUESTID)
VALUES
(@Id, @DbModelId, @BranchId, -1, @ObjectType, 'dbo',
@ObjectName, @SqlScript, NULL, @Checksum, 2, 0,
@CreatedById, @Now, 1,
0, 1, 9999, @CreatedById, @Now, @CreatedById, @Now,
5, @TenantId, NULL)", conn);
insert.Parameters.AddWithValue("@Id", ddlScriptId);
insert.Parameters.AddWithValue("@DbModelId", dbModelId);
insert.Parameters.AddWithValue("@BranchId", branchId);
insert.Parameters.AddWithValue("@ObjectName", objectName);
insert.Parameters.AddWithValue("@ObjectType", objectType);
insert.Parameters.AddWithValue("@SqlScript", sqlScript);
insert.Parameters.AddWithValue("@Checksum", checksum);
insert.Parameters.AddWithValue("@CreatedById", createdById);
insert.Parameters.AddWithValue("@Now", now);
insert.Parameters.AddWithValue("@TenantId", tenantId);
await insert.ExecuteNonQueryAsync();
return ddlScriptId;
}
private static async Task ClearCurrentTipAsync(SqlConnection conn, int dbModelId, string objectName, int objectType, int modifiedById, int tenantId)
{
await using var cmd = new SqlCommand(@"
UPDATE SW.MSWDDLOBJECTHISTORY
SET ISCURRENTTIP = 0, MODIFIEDBYID = @ModifiedById, MODIFIEDON = @Now
WHERE DBMODELID = @DbModelId AND SCHEMANAME = 'dbo' AND OBJECTNAME = @ObjectName
AND OBJECTTYPE = @ObjectType AND ISCURRENTTIP = 1 AND TENANTID = @TenantId", conn);
cmd.Parameters.AddWithValue("@DbModelId", dbModelId);
cmd.Parameters.AddWithValue("@ObjectName", objectName);
cmd.Parameters.AddWithValue("@ObjectType", objectType);
cmd.Parameters.AddWithValue("@ModifiedById", modifiedById);
cmd.Parameters.AddWithValue("@Now", DateTime.UtcNow);
cmd.Parameters.AddWithValue("@TenantId", tenantId);
await cmd.ExecuteNonQueryAsync();
}
private static async Task InsertHistoryVersionAsync(
SqlConnection conn, int dbModelId, string objectName, int objectType, int ddlScriptId, int? previousDdlScriptId,
string checksum, int createdById, int tenantId)
{
int historyId = await AutoNumber.ReserveAsync(conn, "SWDDLOBJECTHISTORY", 1);
var now = DateTime.UtcNow;
await using var insert = new SqlCommand(@"
INSERT INTO SW.MSWDDLOBJECTHISTORY
(DDLOBJECTHISTORYID, DBMODELID, SCHEMANAME, OBJECTNAME, OBJECTTYPE, DDLSCRIPTID, PREVIOUSDDLSCRIPTID,
CHECKSUM, ISCURRENTTIP, VERSION, STATUS, SORTORDER,
CREATEDBYID, CREATEDON, MODIFIEDBYID, MODIFIEDON, SOURCETYPE, TENANTID)
VALUES
(@Id, @DbModelId, 'dbo', @ObjectName, @ObjectType, @DdlScriptId, @PreviousDdlScriptId,
@Checksum, 1, 0, 1, 9999,
@CreatedById, @Now, @CreatedById, @Now, 5, @TenantId)", conn);
insert.Parameters.AddWithValue("@Id", historyId);
insert.Parameters.AddWithValue("@DbModelId", dbModelId);
insert.Parameters.AddWithValue("@ObjectName", objectName);
insert.Parameters.AddWithValue("@ObjectType", objectType);
insert.Parameters.AddWithValue("@DdlScriptId", ddlScriptId);
insert.Parameters.AddWithValue("@PreviousDdlScriptId", (object?)previousDdlScriptId ?? DBNull.Value);
insert.Parameters.AddWithValue("@Checksum", checksum);
insert.Parameters.AddWithValue("@CreatedById", createdById);
insert.Parameters.AddWithValue("@Now", now);
insert.Parameters.AddWithValue("@TenantId", tenantId);
await insert.ExecuteNonQueryAsync();
}
private static async Task<(int PackageId, bool Created)> EnsureUpgradePackageAsync(
SqlConnection conn, OfflinePackageBundle bundle, int dbModelId, byte sequenceInChain, int createdById, int tenantId)
{
var packageCode = bundle.PackageCode ?? throw new InvalidOperationException("Bundle has no PackageCode.");
await using (var check = new SqlCommand(
"SELECT PACKAGEID FROM SW.MSWUPGRADEPACKAGE WHERE PACKAGECODE = @Code AND DBMODELID = @DbModelId", conn))
{
check.Parameters.AddWithValue("@Code", packageCode);
check.Parameters.AddWithValue("@DbModelId", dbModelId);
var existing = await check.ExecuteScalarAsync();
if (existing is not null) return (Convert.ToInt32(existing), false);
}
int packageId = await AutoNumber.ReserveAsync(conn, "SWUPGRADEPACKAGE", 1);
var now = DateTime.UtcNow;
await using var insert = new SqlCommand(@"
INSERT INTO SW.MSWUPGRADEPACKAGE
(PACKAGEID, DBMODELID, PACKAGENAME, PACKAGECODE, PKGSTATUS, DESCRIPTION,
SEQUENCEINCHAIN, SUPERSEDESPACKAGEID, SCOPETYPE, SCOPEVALUE, RELEASEVERSION,
ROLLOUTTYPE, ROLLOUTPERCENT, PACKAGECONTENTTYPE, REQUIREDFEATURECODE,
VERSION, STATUS, SORTORDER, CREATEDBYID, CREATEDON, MODIFIEDBYID, MODIFIEDON,
SOURCETYPE, TENANTID)
VALUES
(@Id, @DbModelId, @Code, @Code, 2, @Desc,
@Seq, NULL, 0, NULL, @ReleaseVersion,
0, 100, @ContentType, NULL,
0, 1, 9999, @CreatedById, @Now, @CreatedById, @Now,
5, @TenantId)", conn);
insert.Parameters.AddWithValue("@Id", packageId);
insert.Parameters.AddWithValue("@DbModelId", dbModelId);
insert.Parameters.AddWithValue("@Code", packageCode);
insert.Parameters.AddWithValue("@Desc", $"Promoted from another Gb5system by SwPackagePromote (source PackageId={bundle.PackageId})");
insert.Parameters.AddWithValue("@Seq", sequenceInChain);
insert.Parameters.AddWithValue("@ReleaseVersion", (object?)bundle.ReleaseVersion ?? DBNull.Value);
insert.Parameters.AddWithValue("@ContentType", bundle.PackageContentType);
insert.Parameters.AddWithValue("@CreatedById", createdById);
insert.Parameters.AddWithValue("@Now", now);
insert.Parameters.AddWithValue("@TenantId", tenantId);
await insert.ExecuteNonQueryAsync();
return (packageId, true);
}
private static async Task RelinkPackageAsync(SqlConnection conn, int packageId, List ddlScriptIds, int tenantId)
{
// Delete-then-reinsert — same semantics as UpgradePackageDAL.ReplaceDdlLines, applied
// here since this is a fresh, standalone tool with no shared DAL reference. Idempotent:
// re-running this against a target it already promoted to just rewrites the same links.
await using (var delete = new SqlCommand("DELETE FROM SW.MSWUPGRADEPACKAGEDDL WHERE PACKAGEID = @PackageId", conn))
{
delete.Parameters.AddWithValue("@PackageId", packageId);
await delete.ExecuteNonQueryAsync();
}
int linked = 0;
for (int i = 0; i < ddlScriptIds.Count; i++)
{
await using var insert = new SqlCommand(@"
INSERT INTO SW.MSWUPGRADEPACKAGEDDL (PACKAGEDDLID, PACKAGEID, DDLSCRIPTID, SEQUENCE, TENANTID)
VALUES (@Id, @PackageId, @DdlScriptId, @Sequence, @TenantId)", conn);
insert.Parameters.AddWithValue("@Id", packageId * 10000 + (i + 1));
insert.Parameters.AddWithValue("@PackageId", packageId);
insert.Parameters.AddWithValue("@DdlScriptId", ddlScriptIds[i]);
insert.Parameters.AddWithValue("@Sequence", (short)Math.Min(i + 1, short.MaxValue));
insert.Parameters.AddWithValue("@TenantId", tenantId);
await insert.ExecuteNonQueryAsync();
linked++;
}
return linked;
}
}
/// Same MAUTONUMBER reservation SQL as FullBaseSchemaLoader's own AutoNumber helper —
/// duplicated, not shared, per this tool's own zero-live-dependency rule (no project reference
/// to another standalone tool, same reasoning as OfflinePackageDecryptor's duplication).
internal static class AutoNumber
{
public static async Task ReserveAsync(SqlConnection conn, string entityCode, int noOfId)
{
const string sql = @"
DECLARE @OutputTable TABLE (AutoId BIGINT);
UPDATE MAUTONUMBER
SET AutoId = AutoId + @NoOfId
OUTPUT INSERTED.AutoId INTO @OutputTable
WHERE EntityCode = @EntityCode;
SELECT AutoId FROM @OutputTable;";
await using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@NoOfId", noOfId);
cmd.Parameters.AddWithValue("@EntityCode", entityCode);
var result = await cmd.ExecuteScalarAsync()
?? throw new InvalidOperationException($"MAUTONUMBER has no row for EntityCode='{entityCode}' on this target — seed it first.");
long newAutoId = Convert.ToInt64(result);
return (int)(newAutoId - noOfId);
}
}