diff --git a/CHANGELOG.md b/CHANGELOG.md index 1899968..44d07e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。 ### 🛠️ 問題修正與優化 +- PostgreSQL 邏輯搬移保留並校驗主鍵 constraint/索引名稱;跨 SQLite 無法保留的自訂名稱與 PostgreSQL 目標名稱碰撞改在預檢阻擋,避免已驗證卻靜默更名。 - **PostgreSQL 索引鍵預檢**:讀取實際 block_size,標準 8 KiB page 下逐列檢查 PK 與普通索引的保守 2048-byte tuple 預算,包含 UTF-8、複合鍵與對齊成本。超限在確認前拒絕,不依賴壓縮或等 INSERT 才失敗;未索引文字仍保留原欄位限額。 - **SQL Server 搬移參數**:TimeSpan 不再被 DbType.Time 轉成 DateTime,NULL time 明確保留 SQL time;binary 欄位的 NULL 不再推導成 nvarchar。每批資料參數改為 2098,保留 RPC 額度。新增正式來源 SQL Server 2019 ↔ 2022 隔離雙向回歸與末批失敗/安全重試測試,不宣稱 PK/FK/index/identity 搬移或所有版本已驗證。 - 邏輯搬移先取得 byte length 與 SQL 限額投影再讀取 BLOB/文字,避免超過 1 MiB 才拒絕時已配置整個大陣列;單欄與累計預算在 materialize 前檢查,超限保持空目標。 diff --git a/docs/LOGICAL_MIGRATION_MATRIX.md b/docs/LOGICAL_MIGRATION_MATRIX.md index ef4a4eb..d1fa7dc 100644 --- a/docs/LOGICAL_MIGRATION_MATRIX.md +++ b/docs/LOGICAL_MIGRATION_MATRIX.md @@ -17,7 +17,7 @@ 實作適用明確手動給 ID 的業務表:例如 `customers(id INTEGER NOT NULL, label TEXT COLLATE BINARY NOT NULL, note TEXT, payload BLOB, PRIMARY KEY(id)) STRICT, WITHOUT ROWID`,可帶 `customer_label(label)`、`customer_note(note,id)` 普通索引;複合主鍵如 `lines(customer,code)` 同樣保留順序、唯一性及 NOT NULL。所有索引 DDL 都列在確認預覽,建立後正式 catalog 再讀回驗證,缺索引/改欄位順序也會整批回滾。 - SQLite 文字只接受 BINARY(隱含或明確),PostgreSQL 必須是內建 `pg_catalog."C"`;兩端 UTF-8,逐欄嚴格解碼,拒絕 NUL、無效 UTF-8,不偷偷替換字元。保留空字串、NULL、Unicode、大小寫與 byte-order 比較語意;不承諾兩引擎字串函式或 LIKE 行為相同。 -- SQLite PK 表要求完整可辨識 grammar、明確 NOT NULL 與 WITHOUT ROWID,避免 INTEGER PRIMARY KEY 的 rowid alias 自動給 ID 行為被偷偷改成 PostgreSQL 無 sequence 的 bigint。單欄整數/文字/二進位與複合 PK 均可;不保證引擎自動產生的 PK constraint/內部索引名稱或實體配置相同。 +- SQLite PK 表要求完整可辨識 grammar、明確 NOT NULL 與 WITHOUT ROWID,避免 INTEGER PRIMARY KEY 的 rowid alias 自動給 ID 行為被偷偷改成 PostgreSQL 無 sequence 的 bigint。單欄整數/文字/二進位與複合 PK 均可。PostgreSQL → PostgreSQL 保留 catalog 的主鍵 constraint/背後索引名稱,名稱納入確認 fingerprint 與目標結構驗證。SQLite → PostgreSQL 明確產生 `表名前 58 字元_pkey`,並預檢它與所有表/普通索引/其他主鍵索引的 public 名稱空間碰撞,不讓引擎自動另取名稱。PostgreSQL → SQLite 僅允許上述預設命名慣例;其他名稱無法保留 `ON CONFLICT ON CONSTRAINT` 依賴,預檢拒絕。catalog 無法區分手動指定但恰好符合預設慣例的名稱,跨 SQLite 路徑明確不保證此預設名稱/內部索引名稱與實體配置;需要名稱依賴的資料庫應使用 PostgreSQL 目標。 - 普通非 unique btree 索引保留名稱、欄位與順序,只允許升冪、預設內建 opclass、無 expression/partial/INCLUDE/自訂 storage。SQLite 升冪 NULL 在前,目標 PostgreSQL 明確 `ASC NULLS FIRST`;反向 nullable 欄位索引若為 PostgreSQL 預設 NULLS LAST 則拒絕,NOT NULL 索引沒有這項差異。DESC、unique(PK 除外)、自訂定序/opclass、clustered/replica identity 索引都阻擋。 - SQLite/PostgreSQL schema+索引+資料仍為單一交易,不引入逐表 partial success。每次重新預檢/最終驗證涵蓋 PK、索引、內容;已提交或 ack lost 不可盲重試。 - 有界讀取先在 SQL 投影取 byte length、超限 CASE 回傳 NULL,再檢查預算,才 materialize 欄位。BLOB 不會先 GetValue 完整大陣列;SQLite expression 用已限額的 byte[] 讀取(避開 provider GetBytes 的 metadata 路徑),PostgreSQL 分段讀取。NULL 與空欄位至少計入 8 bytes 預算。這是應用端快照限額,不是對引擎自身 page cache/SQL 執行記憶體的硬上限。 diff --git a/mySQLPunk.Core/Models/LogicalMigration.cs b/mySQLPunk.Core/Models/LogicalMigration.cs index 07fa9b4..022a443 100644 --- a/mySQLPunk.Core/Models/LogicalMigration.cs +++ b/mySQLPunk.Core/Models/LogicalMigration.cs @@ -5,6 +5,7 @@ public sealed record LogicalMigrationIndex(string Name, IReadOnlyList Co public sealed record LogicalMigrationTable(string Name, IReadOnlyList Columns, long Rows, string ContentHash) { public IReadOnlyList PrimaryKey { get; init; } = Array.Empty(); + public string? PrimaryKeyName { get; init; } public IReadOnlyList Indexes { get; init; } = Array.Empty(); } public sealed record LogicalMigrationPreview( diff --git a/mySQLPunk.Core/Services/LogicalMigrationKeys.cs b/mySQLPunk.Core/Services/LogicalMigrationKeys.cs index c3a1c41..b10388b 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationKeys.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationKeys.cs @@ -26,12 +26,19 @@ internal static bool PostgreSqlIndexKeyFits(int blockSize, IReadOnlyList PrimaryKey, IReadOnlyList Indexes); + private static string DefaultPrimaryKeyName(string table) => table[..Math.Min(table.Length, 58)] + "_pkey"; + + private static LogicalMigrationTable TargetTable(DatabaseProviderKind provider, LogicalMigrationTable table) => + table with { PrimaryKeyName = table.PrimaryKey.Count == 0 || provider == DatabaseProviderKind.Sqlite + ? null : table.PrimaryKeyName ?? DefaultPrimaryKeyName(table.Name) }; + + private sealed record Keys(IReadOnlyList PrimaryKey, IReadOnlyList Indexes, string? PrimaryKeyName); private static async Task ReadKeysAsync(DbConnection connection, DbTransaction? transaction, DatabaseProviderKind provider, string table, List columns, List blockers, CancellationToken token) { var primary = new List(); + string? primaryName = null; var indexes = new List(); if (provider == DatabaseProviderKind.Sqlite) { @@ -92,14 +99,24 @@ private static async Task ReadKeysAsync(DbConnection connection, DbTransac else { await using var command = connection.CreateCommand(); command.Transaction = transaction; - command.CommandText = "SELECT ic.relname, i.indisprimary, a.attname FROM pg_index i JOIN pg_class c ON c.oid=i.indrelid JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_class ic ON ic.oid=i.indexrelid CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY k(attnum,position) LEFT JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=k.attnum WHERE n.nspname='public' AND c.relname=@table ORDER BY ic.relname COLLATE \"C\", k.position;"; + command.CommandText = "SELECT ic.relname, i.indisprimary, a.attname, pc.conname FROM pg_index i JOIN pg_class c ON c.oid=i.indrelid JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_class ic ON ic.oid=i.indexrelid LEFT JOIN pg_constraint pc ON pc.conrelid=c.oid AND pc.conindid=i.indexrelid AND pc.contype='p' CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY k(attnum,position) LEFT JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum=k.attnum WHERE n.nspname='public' AND c.relname=@table ORDER BY ic.relname COLLATE \"C\", k.position;"; var parameter = command.CreateParameter(); parameter.ParameterName = "@table"; parameter.Value = table; command.Parameters.Add(parameter); await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false); var indexFields = new SortedDictionary>(StringComparer.Ordinal); while (await reader.ReadAsync(token).ConfigureAwait(false)) { if (reader.IsDBNull(2)) { blockers.Add("不支援 expression 索引:" + reader.GetString(0)); continue; } - if (reader.GetBoolean(1)) primary.Add(reader.GetString(2)); + if (reader.GetBoolean(1)) + { + primary.Add(reader.GetString(2)); + if (reader.IsDBNull(3)) blockers.Add("無法識別主鍵 constraint 名稱:" + table); + else + { + primaryName = reader.GetString(3); + if (!Identifier.IsMatch(primaryName) || primaryName != reader.GetString(0)) + blockers.Add("主鍵 constraint/索引名稱超出可忠實保留子集:" + table); + } + } else { var name = reader.GetString(0); @@ -112,6 +129,6 @@ private static async Task ReadKeysAsync(DbConnection connection, DbTransac } if (primary.Any(name => columns.All(column => column.Name != name || column.Nullable))) blockers.Add("主鍵必須明確 NOT NULL:" + table); - return new(primary.ToArray(), indexes.OrderBy(index => index.Name, StringComparer.Ordinal).ToArray()); + return new(primary.ToArray(), indexes.OrderBy(index => index.Name, StringComparer.Ordinal).ToArray(), primaryName); } } diff --git a/mySQLPunk.Core/Services/LogicalMigrationService.cs b/mySQLPunk.Core/Services/LogicalMigrationService.cs index 2ecb1a5..b97a53e 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationService.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationService.cs @@ -68,6 +68,12 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co .Concat(destination.Blockers.Select(reason => "目標:" + reason)).ToList(); if (target.Provider == DatabaseProviderKind.PostgreSql) { + var relationNames = origin.Tables.Select(table => table.Name) + .Concat(origin.Tables.SelectMany(table => table.Indexes.Select(index => index.Name))) + .Concat(origin.Tables.Where(table => table.PrimaryKey.Count > 0) + .Select(table => TargetTable(target.Provider, table).PrimaryKeyName!)); + if (relationNames.GroupBy(name => name, StringComparer.Ordinal).Any(group => group.Count() > 1)) + blockers.Add("表/普通索引/主鍵索引名稱在 PostgreSQL public 名稱空間衝突;不允許引擎自動更名。"); if (destination.BlockSize != 8192) blockers.Add("PostgreSQL 索引預算只驗證過 8192-byte block_size;未知/其他 page 大小不能放行。"); foreach (var table in origin.Tables) { @@ -88,6 +94,9 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co if (target.Provider == DatabaseProviderKind.Sqlite && (origin.Tables.GroupBy(table => table.Name, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() > 1) || origin.Tables.Any(table => table.Columns.GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() > 1)))) blockers.Add("來源名稱只差大小寫;SQLite 無法忠實保存這些不同的表/欄位名稱。"); + if (target.Provider == DatabaseProviderKind.Sqlite) + foreach (var table in origin.Tables.Where(table => table.PrimaryKeyName != null && table.PrimaryKeyName != DefaultPrimaryKeyName(table.Name))) + blockers.Add(table.Name + ":SQLite 無法保留 PostgreSQL 明確主鍵 constraint/索引名稱 " + table.PrimaryKeyName + ";請使用 PostgreSQL 目標。"); if (target.Provider == DatabaseProviderKind.Sqlite && origin.Tables.Select(table => table.Name) .Concat(origin.Tables.SelectMany(table => table.Indexes.Select(index => index.Name))) .GroupBy(name => name, StringComparer.OrdinalIgnoreCase).Any(group => group.Count() > 1)) @@ -180,7 +189,7 @@ private static async Task RunCoreAsync(ConnectionProfile stage = "逐欄型別、NULL、內容與結構驗證"; observer?.Invoke("verify", destination, targetTransaction); var written = await ReadSnapshotAsync(destination, targetTransaction, target.Provider, true, token).ConfigureAwait(false); - if (written.Blockers.Count != 0 || JsonSerializer.Serialize(written.Tables) != JsonSerializer.Serialize(sourceSnapshot.Tables)) + if (written.Blockers.Count != 0 || JsonSerializer.Serialize(written.Tables) != JsonSerializer.Serialize(sourceSnapshot.Tables.Select(table => TargetTable(target.Provider, table)))) throw new InvalidOperationException("目標結構或內容 SHA-256 多重集合驗證不符。"); var sourceFinal = await ReadSnapshotAsync(origin, sourceTransaction, source.Provider, true, token).ConfigureAwait(false); if (sourceFinal.Blockers.Count != 0 || JsonSerializer.Serialize(sourceFinal.Tables) != JsonSerializer.Serialize(sourceSnapshot.Tables)) @@ -306,7 +315,7 @@ private static async Task ReadSnapshotAsync(DbConnection connection, D rows.Add(row); } } - tables.Add(new(definition.Name, columns.AsReadOnly(), rows.Count, ContentHash(rows)) { PrimaryKey = keys.PrimaryKey, Indexes = keys.Indexes }); + tables.Add(new(definition.Name, columns.AsReadOnly(), rows.Count, ContentHash(rows)) { PrimaryKey = keys.PrimaryKey, PrimaryKeyName = keys.PrimaryKeyName, Indexes = keys.Indexes }); data.Add(definition.Name, rows); } return new(version, identity, tables, data, blockers, blockSize); @@ -400,7 +409,7 @@ private static string CreateTableSql(DatabaseProviderKind provider, LogicalMigra Quote(column.Name) + " " + (column.Kind == "integer" ? provider == DatabaseProviderKind.Sqlite ? "INTEGER" : "bigint" : column.Kind == "text" ? provider == DatabaseProviderKind.Sqlite ? "TEXT COLLATE BINARY" : "text COLLATE \"C\"" : provider == DatabaseProviderKind.Sqlite ? "BLOB" : "bytea") + (column.Nullable ? "" : " NOT NULL"))) + - (table.PrimaryKey.Count == 0 ? "" : ",PRIMARY KEY (" + string.Join(",", table.PrimaryKey.Select(Quote)) + ")") + + (table.PrimaryKey.Count == 0 ? "" : "," + (provider == DatabaseProviderKind.PostgreSql ? "CONSTRAINT " + Quote(TargetTable(provider, table).PrimaryKeyName!) + " " : "") + "PRIMARY KEY (" + string.Join(",", table.PrimaryKey.Select(Quote)) + ")") + ")" + (provider == DatabaseProviderKind.Sqlite ? " STRICT" + (table.PrimaryKey.Count == 0 ? ";" : ", WITHOUT ROWID;") : ";"); private static string CreateIndexSql(DatabaseProviderKind provider, LogicalMigrationTable table, LogicalMigrationIndex index) => diff --git a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs index 5477b31..97c4026 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs @@ -127,6 +127,7 @@ public static async Task PostgreSqlLiveAsync() { await admin.ExecuteAsync("postgres", "CREATE DATABASE \"" + postgres.Database + "\";"); created = true; await PostgreSqlKeyBudgetAsync(root, postgres, admin); + await PostgreSqlPrimaryKeyNamesAsync(root, postgres, admin); var sqlite = Profile(Path.Combine(root, "source.db")); using var origin = DatabaseProviderFactory.Create(sqlite); await origin.ExecuteAsync(sqlite.Database, "CREATE TABLE sample (number INTEGER, payload BLOB NOT NULL) STRICT; INSERT INTO sample VALUES (-9223372036854775808,X'00FF'),(9223372036854775807,X''),(NULL,X'01'),(NULL,X'01');" + BusinessSql); @@ -185,6 +186,69 @@ public static async Task PostgreSqlLiveAsync() } } + private static async Task PostgreSqlPrimaryKeyNamesAsync(string root, ConnectionProfile postgres, IDatabaseSession admin) + { + foreach (var scenario in new[] { "table", "index", "truncation" }) + { + var source = Profile(Path.Combine(root, "pk-collision-" + scenario + ".db")); + using var owner = DatabaseProviderFactory.Create(source); + var longPrefix = new string('a', 58); + var sql = scenario == "truncation" + ? "CREATE TABLE " + longPrefix + "a(id INTEGER NOT NULL,PRIMARY KEY(id)) STRICT, WITHOUT ROWID; CREATE TABLE " + longPrefix + "b(id INTEGER NOT NULL,PRIMARY KEY(id)) STRICT, WITHOUT ROWID;" + : "CREATE TABLE records(id INTEGER NOT NULL,label TEXT NOT NULL,PRIMARY KEY(id)) STRICT, WITHOUT ROWID;" + + (scenario == "table" ? "CREATE TABLE records_pkey(id INTEGER) STRICT;" : "CREATE INDEX records_pkey ON records(label);"); + await owner.ExecuteAsync(source.Database, sql); + var denied = await LogicalMigrationService.PreviewAsync(source, postgres, 16); + Check(!denied.CanStart && denied.Blockers.Any(reason => reason.Contains("名稱空間衝突")), "Real catalog detects PostgreSQL PK name collision: " + scenario); + Check((await LogicalMigrationService.RunAsync(source, postgres, 16, denied, postgres.Database)).Outcome == LogicalMigrationOutcome.Refused, "Collision refused before DDL"); + var count = await admin.ExecuteAsync(postgres.Database, "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public';"); + Check(Convert.ToInt64(count.Rows.Single()[0]) == 0, "Collision leaves PostgreSQL target empty"); + Console.WriteLine("PASS real PG primary key namespace collision: " + scenario); + } + var target = postgres.Clone(); target.Database = "mysqlpunk_pk_names_" + Guid.NewGuid().ToString("N"); + await admin.ExecuteAsync("postgres", "CREATE DATABASE \"" + target.Database + "\";"); + using var targetOwner = DatabaseProviderFactory.Create(target); + try + { + await admin.ExecuteAsync(postgres.Database, "CREATE TABLE records(id bigint NOT NULL,label text COLLATE \"C\" NOT NULL,PRIMARY KEY(id,label)); INSERT INTO records VALUES(1,'臺灣😀'); CREATE INDEX records_label ON records(label ASC NULLS FIRST);"); + var beforeRename = await LogicalMigrationService.PreviewAsync(postgres, target, 16); + Check(beforeRename.CanStart, "Default PG PK preview allowed"); + await admin.ExecuteAsync(postgres.Database, "ALTER TABLE records RENAME CONSTRAINT records_pkey TO explicit_records_pk;"); + var renamed = await LogicalMigrationService.PreviewAsync(postgres, target, 16); + Check(renamed.CanStart && renamed.Tables.Single().PrimaryKeyName == "explicit_records_pk" && renamed.Statements.Any(sql => sql.Contains("CONSTRAINT \"explicit_records_pk\"")), "Actual PG constraint catalog reaches preview and DDL"); + Check(beforeRename.Fingerprint != renamed.Fingerprint, "PK rename changes fingerprint"); + Check((await LogicalMigrationService.RunAsync(postgres, target, 16, beforeRename, target.Database)).Outcome == LogicalMigrationOutcome.RolledBack, "Stale constraint name confirmation refused"); + var sqlite = Profile(Path.Combine(root, "explicit-pk-target.db")); CreateEmpty(sqlite.Database); + var denied = await LogicalMigrationService.PreviewAsync(postgres, sqlite, 3); + Check(!denied.CanStart && denied.Blockers.Any(reason => reason.Contains("explicit_records_pk")), "Explicit PG PK name cannot silently become SQLite internal PK"); + Check((await LogicalMigrationService.RunAsync(postgres, sqlite, 3, denied, sqlite.Database)).Outcome == LogicalMigrationOutcome.Refused && await ObjectCountAsync(sqlite) == 0, "Explicit PK SQLite rejection leaves target empty"); + Check((await LogicalMigrationService.RunAsync(postgres, target, 16, renamed, target.Database)).Outcome == LogicalMigrationOutcome.Verified, "PG -> PG preserves explicit PK name"); + var catalog = await targetOwner.ExecuteAsync(target.Database, "SELECT conname,ic.relname FROM pg_constraint pc JOIN pg_class ic ON ic.oid=pc.conindid WHERE pc.conrelid='records'::regclass AND pc.contype='p';"); + Check(catalog.Rows.Single().All(value => Equals(value, "explicit_records_pk")), "Target actual constraint and backing index names match"); + await targetOwner.ExecuteAsync(target.Database, "INSERT INTO records VALUES(1,'臺灣😀') ON CONFLICT ON CONSTRAINT explicit_records_pk DO NOTHING;"); + var rows = await targetOwner.ExecuteAsync(target.Database, "SELECT count(*) FROM records;"); + Check(Convert.ToInt64(rows.Rows.Single()[0]) == 1, "ON CONFLICT ON CONSTRAINT dependency remains functional"); + await targetOwner.ExecuteAsync(target.Database, "DROP TABLE records;"); + var corrupted = await LogicalMigrationService.RunWithObserverAsync(postgres, target, 16, renamed, target.Database, CancellationToken.None, + (stage, connection, transaction) => + { + if (stage != "verify") return; + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = "ALTER TABLE records RENAME CONSTRAINT explicit_records_pk TO wrong_records_pk;"; command.ExecuteNonQuery(); + }); + Check(corrupted.Outcome == LogicalMigrationOutcome.RolledBack, "Actual target PK rename fails schema verification"); + var empty = await targetOwner.ExecuteAsync(target.Database, "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public';"); + Check(Convert.ToInt64(empty.Rows.Single()[0]) == 0, "PK verification failure rolls back target objects"); + Console.WriteLine("PASS real PG explicit PK catalog/DDL/fingerprint/ON CONFLICT/schema-fault and SQLite refusal"); + } + finally + { + await admin.ExecuteAsync(postgres.Database, "DROP TABLE IF EXISTS records;"); + Npgsql.NpgsqlConnection.ClearAllPools(); + await admin.ExecuteAsync("postgres", "DROP DATABASE \"" + target.Database + "\";"); + } + } + private static async Task PostgreSqlKeyBudgetAsync(string root, ConnectionProfile postgres, IDatabaseSession admin) { var version = await admin.ExecuteAsync(postgres.Database, "SHOW server_version;");