Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- **邏輯搬移的文字、主鍵與索引子集**:SQLite STRICT ↔ PostgreSQL 保留有效 UTF-8 TEXT(BINARY/C)、明確非自增單欄/複合主鍵、普通升冪索引與欄位順序。預覽包含索引 DDL,來源變更失效、取消、缺索引或內容不符仍整批回滾;FK、其他定序、default、identity、特殊索引依舊預檢拒絕,不宣稱所有跨引擎組合完成。
- **可驗證邏輯搬移的第一階段**:SQL Server 專用相容工具加入受限雙向跨版本;Avalonia 新增 SQLite STRICT ↔ PostgreSQL 的整數/二進位搬移,預覽來源/目標版本、schema/data 轉換與阻擋原因,確認後才寫入另建空目標。單一交易、分批、內容校驗與不確定狀態禁止重試,不將一般資料傳輸或原生備份宣稱為無限制跨版本/跨引擎還原。

- **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 尚僅離線/mock 驗證,不宣稱真引擎矩陣通過。
- **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 完整矩陣仍僅離線/mock,2019 ↔ 2022 另有受限隔離真引擎回歸,不宣稱全部版本組合通過。

- **系統資料庫規格書樣版(Windows,待最終匯出驗收)**:DOCX 新增第三種樣版,提供 A4 直式資料表清單、主鍵與索引摘要、淡藍色可編輯欄位表,以及 PK/NN 和長度/精度欄;中文名稱沿用資料庫註解。
- **Windows 建置與啟動腳本**:根目錄新增 `build.bat` 與 `run.bat`,支援 `debug` / `release` 且預設 Debug;自動尋找 MSBuild 並還原套件,`run.bat` 只在編譯成功後啟動對應版本。
Expand Down Expand Up @@ -54,6 +54,8 @@
- **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 索引鍵預檢**:讀取實際 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 前檢查,超限保持空目標。
- SQLite SQL 與執行計畫改在背景執行,長查詢透過原生 progress handler 中斷,不再凍結 UI;取消後等實際語句停止才釋放操作鎖。取消訊息明示先前已提交語句不會自動回滾,避免盲目重試。

Expand Down
3 changes: 2 additions & 1 deletion docs/LOGICAL_MIGRATION_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

| 來源 → 目標 | 程式允許的版本 | 可執行範圍 | 驗證區分 |
| --- | --- | --- | --- |
| SQL Server → SQL Server | 2016/2017/2019/2022/2025 任意雙向及同版本,共 25 組 | Windows 既有專用工具;dbo heap、既有型別白名單、同定序、無 advanced features | 25 組預檢及交易 mock;不是 25 組真引擎驗證 |
| SQL Server → SQL Server | 2016/2017/2019/2022/2025 任意雙向及同版本,共 25 組 | Windows 既有專用工具;dbo heap、既有型別白名單、同定序、無 advanced features | 25 組預檢及交易 mock;另有正式來源 2019 ↔ 2022 隔離回歸,1007 列/7 欄+空表,不是 25 組真引擎驗證 |
| SQLite → SQLite | 3.37–3.53 | STRICT、INTEGER/BLOB/TEXT、NULL/NOT NULL、非自增 PK、普通索引;詳細子集見下文 | 本機實際 SQLite 3.53.3;歷史 minor 版本未逐一跑 |
| SQLite → PostgreSQL | SQLite 3.37–3.53 → PostgreSQL 14–17 | INTEGER → bigint、BLOB → bytea、TEXT BINARY → text C;非自增 PK、普通索引 | 隔離 CI PostgreSQL 16 fixture 加入文字/鍵/索引雙向 round-trip;只以這次新 head 結果為證據 |
| PostgreSQL → SQLite | PostgreSQL 14–17 → SQLite 3.37–3.53 | 上述型別反向映射;PK → WITHOUT ROWID、普通索引 | 同上;14/15/17 未實機跑 |
Expand All @@ -32,6 +32,7 @@ SQLite/PostgreSQL 工具入口在 Avalonia 工作台「邏輯搬移」。來
- 預覽顯示實際版本、方向、目標、表、列數、轉換 SQL 與阻擋原因,不顯示資料值。來源內容也納入確認 fingerprint;輸入完整目標名稱才可執行,確認後來源內容(包括相同列數下的變更)/結構/實際引擎識別/目標變動會拒絕。
- SQLite/PostgreSQL 全部 DDL+資料在單一目標交易。SQLite 用 immediate 交易;PostgreSQL 持有工具專用 advisory lock,來源全部表 SHARE lock 後讀取並在提交前再檢查。這不是在線無鎖搬移;請用隔離副本/維護時段,外部工具不會自動遵守 advisory lock,也不能宣稱能禁止其他管理者在最後檢查之後建立額外物件。
- 每批至多 100 列/200 參數,最多 100,000 列;每欄至多 1 MiB,每輪快照資料至多 64 MiB。驗證 schema、PK/索引、列數、Int64/text/binary/NULL 的 SHA-256 多重集合(保留重複列)。不保證隱含 rowid、實體排序或儲存配置跨引擎相同。
- PostgreSQL 目標先讀 `SHOW block_size` 並納入確認 fingerprint;只接受標準 8192-byte page。PK 與每個普通索引各自逐列限制保守 tuple 預算 2048 bytes,32 欄以內:保留 40 bytes 給 tuple header/TID/NULL bitmap/對齊,每欄 bigint 8 bytes,text/bytea 加 4-byte varlena header 後向上對齊到 8 bytes,文字使用 UTF-8 byte 數,複合鍵加總。這是刻意低於標準 btree 頁面上限的應用白名單,不是宣稱精確最大可用大小;不依賴壓縮。超限在預覽阻擋且列出表/鍵名稱;未索引文字仍可達原本 1 MiB 欄位限額。非標準 page/未知能力 fail closed。
- 取消等實際工作停止,SQLite 用原生 progress handler,PostgreSQL 使用 provider cancellation;工作台 gate 持續到對話框關閉,不能同時 SQL/edit/delete/connect。變更目標/版本/認證即失效原預覽。

## 重試與恢復
Expand Down
20 changes: 20 additions & 0 deletions mySQLPunk.Core/Services/LogicalMigrationKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ namespace MySqlPunk.Core.Services;

public static partial class LogicalMigrationService
{
internal static bool PostgreSqlIndexKeyFits(int blockSize, IReadOnlyList<object?> values)
{
if (blockSize != 8192 || values.Count is < 1 or > 32) return false;
long bytes = 40;
foreach (var value in values)
{
long width = value switch
{
null => 0,
long => 8,
string text => 4L + System.Text.Encoding.UTF8.GetByteCount(text),
byte[] binary => 4L + binary.Length,
_ => 2049
};
bytes += (width + 7) / 8 * 8;
if (bytes > 2048) return false;
}
return true;
}

private sealed record Keys(IReadOnlyList<string> PrimaryKey, IReadOnlyList<LogicalMigrationIndex> Indexes);

private static async Task<Keys> ReadKeysAsync(DbConnection connection, DbTransaction? transaction,
Expand Down
24 changes: 21 additions & 3 deletions mySQLPunk.Core/Services/LogicalMigrationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static partial class LogicalMigrationService
private const long MaximumBytes = 64 * 1024 * 1024;
private static readonly Regex Identifier = new("\\A[A-Za-z_][A-Za-z0-9_]{0,62}\\z", RegexOptions.CultureInvariant);
private sealed record Snapshot(string Version, string Identity, List<LogicalMigrationTable> Tables,
Dictionary<string, List<object?[]>> Data, List<string> Blockers);
Dictionary<string, List<object?[]>> Data, List<string> Blockers, int BlockSize);

public static Task<LogicalMigrationPreview> PreviewAsync(ConnectionProfile source, ConnectionProfile target,
int selectedTargetMajor, CancellationToken cancellationToken = default) =>
Expand Down Expand Up @@ -66,6 +66,22 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co
{
var blockers = origin.Blockers.Select(reason => "來源:" + reason)
.Concat(destination.Blockers.Select(reason => "目標:" + reason)).ToList();
if (target.Provider == DatabaseProviderKind.PostgreSql)
{
if (destination.BlockSize != 8192) blockers.Add("PostgreSQL 索引預算只驗證過 8192-byte block_size;未知/其他 page 大小不能放行。");
foreach (var table in origin.Tables)
{
var keys = table.Indexes.Select(index => (index.Name, index.Columns))
.Concat(table.PrimaryKey.Count == 0 ? Array.Empty<(string, IReadOnlyList<string>)>() : new[] { ("PRIMARY KEY", table.PrimaryKey) });
foreach (var key in keys)
{
var ordinals = key.Item2.Select(name => table.Columns.ToList().FindIndex(column => column.Name == name)).ToArray();
if (ordinals.Length is < 1 or > 32 || ordinals.Any(ordinal => ordinal < 0) ||
origin.Data[table.Name].Any(row => !PostgreSqlIndexKeyFits(destination.BlockSize, ordinals.Select(ordinal => row[ordinal]).ToArray())))
blockers.Add(table.Name + "/" + key.Item1 + ":索引鍵超出保守 2048-byte tuple 預算(含 UTF-8/複合鍵/NULL 與對齊成本),不能依賴壓縮或執行時才失敗。");
}
}
}
if (destination.Tables.Count != 0) blockers.Add("目標必須是另建的空資料庫,不能覆蓋或續寫既有表。");
if (origin.Tables.Count == 0) blockers.Add("來源沒有可搬移資料表。");
if (source.Provider == target.Provider && origin.Identity == destination.Identity) blockers.Add("實際引擎識別為同一資料庫,不允許搬移至自身。");
Expand All @@ -86,7 +102,7 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co
{
Source = new { source.Provider, source.Host, source.Port, source.Database },
Target = new { target.Provider, target.Host, target.Port, target.Database },
origin.Version, origin.Identity, TargetIdentity = destination.Identity, TargetVersion = destination.Version, origin.Tables, Statements = statements, Blockers = blockers
origin.Version, origin.Identity, TargetIdentity = destination.Identity, TargetVersion = destination.Version, destination.BlockSize, origin.Tables, Statements = statements, Blockers = blockers
}));
return new(origin.Version, destination.Version, target.Database, origin.Tables.AsReadOnly(), statements,
blockers.AsReadOnly(), fingerprint);
Expand Down Expand Up @@ -196,6 +212,8 @@ private static async Task<Snapshot> ReadSnapshotAsync(DbConnection connection, D
DatabaseProviderKind provider, bool readData, CancellationToken token)
{
var blockers = new List<string>();
var blockSize = provider == DatabaseProviderKind.PostgreSql
? Convert.ToInt32(await ScalarAsync(connection, transaction, "SHOW block_size;", token), CultureInfo.InvariantCulture) : 0;
var version = Convert.ToString(await ScalarAsync(connection, transaction,
provider == DatabaseProviderKind.Sqlite ? "SELECT sqlite_version();" : "SHOW server_version;", token), CultureInfo.InvariantCulture) ?? "";
var identity = connection is SqliteConnection sqlite ? Path.GetFullPath(sqlite.DataSource) :
Expand Down Expand Up @@ -291,7 +309,7 @@ private static async Task<Snapshot> ReadSnapshotAsync(DbConnection connection, D
tables.Add(new(definition.Name, columns.AsReadOnly(), rows.Count, ContentHash(rows)) { PrimaryKey = keys.PrimaryKey, Indexes = keys.Indexes });
data.Add(definition.Name, rows);
}
return new(version, identity, tables, data, blockers);
return new(version, identity, tables, data, blockers, blockSize);
}

internal static byte[] ReadBoundedBytes(DbDataReader reader, int ordinal, long length, CancellationToken token)
Expand Down
Loading
Loading