From 72a16942f1b8b794d89cb6674405018cc8591db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 12:06:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=94=AF=E6=8F=B4=E5=8F=97?= =?UTF-8?q?=E9=99=90=20SQL=20Server=20=E9=9B=99=E5=90=91=E8=B7=A8=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=82=8F=E8=BC=AF=E6=90=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先只接受新引擎搬到舊引擎,讓相同結構的升版與同版本搬移也被拒絕。保留型別白名單、同定序、空目標與交易內容驗證,開放 2016–2025 的雙向組合,並調整 Windows 入口名稱。 補上 25 組預檢和交易 mock;這不代表真實跨版本矩陣已通過,也不改變新版備份不能原生還原到舊引擎的限制。 --- docs/MSSQL_COMPATIBILITY_MIGRATION.md | 11 ++++------- mySQLPunk/Form1.cs | 2 +- mySQLPunk/SqlServerMigrationForm.cs | 4 ++-- mySQLPunk/lib/SqlServerCompatibilityMigration.cs | 1 - tests/SqlServerMigrationTests.cs | 10 ++++++++++ 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/MSSQL_COMPATIBILITY_MIGRATION.md b/docs/MSSQL_COMPATIBILITY_MIGRATION.md index c6c0405..4d8f1dc 100644 --- a/docs/MSSQL_COMPATIBILITY_MIGRATION.md +++ b/docs/MSSQL_COMPATIBILITY_MIGRATION.md @@ -1,16 +1,13 @@ -# SQL Server 降版相容搬移(有限範圍預覽版) +# SQL Server 雙向跨版本相容搬移(有限範圍預覽版) 這是 Windows 資料庫右鍵的獨立邏輯搬移工具,不是完整資料庫降版器。一般資料傳輸仍只驗證列數,不能當成降版驗證。 ## 引擎矩陣與驗證狀態 -| 來源引擎 | 可選舊版目標 | 狀態 | +| 來源引擎 | 可選目標 | 狀態 | | --- | --- | --- | -| 2025 (17) | 2022 (16)、2019 (15)、2017 (14)、2016 (13) | 預檢與交易流程只有 mock 驗證 | -| 2022 (16) | 2019 (15)、2017 (14)、2016 (13) | 同一受限實作;沒有真引擎矩陣證據 | -| 2019 (15) | 2017 (14)、2016 (13) | 同上 | -| 2017 (14) | 2016 (13) | 同上 | -| 2016 (13) 或更早/未知 | 無 | 拒絕;不猜測最低版本 | +| 2016–2025 (13–17) | 2016–2025 (13–17),含升版、降版、同版本 | 25 組預檢與交易流程 mock;沒有完整真引擎矩陣證據 | +| 更早/未知 | 無 | 拒絕;不猜測最低版本 | UI 不預選引擎,必須明確選取目標,並與 SERVERPROPERTY 實際版本一致。這張表代表程式允許預檢的範圍,不代表各版本已通過真 SQL Server 驗證,也不宣稱生產支援。Linux/macOS 目前只有共用核心 mock 測試,尚無此搬移 UI。 diff --git a/mySQLPunk/Form1.cs b/mySQLPunk/Form1.cs index 3690c97..a35c7d1 100644 --- a/mySQLPunk/Form1.cs +++ b/mySQLPunk/Form1.cs @@ -13038,7 +13038,7 @@ private void AddTableGroupMenuItems(ContextMenuStrip menu, TreeNode node) TreeDatabaseTarget migrationTarget = BuildTargetFromNode(node); if (migrationTarget != null && migrationTarget.ProviderName == "mssql") { - ToolStripMenuItem migrationItem = new ToolStripMenuItem("SQL Server 降版相容搬移..."); + ToolStripMenuItem migrationItem = new ToolStripMenuItem("SQL Server 雙向跨版本相容搬移..."); migrationItem.Click += (s, ev) => OpenSqlServerMigration(migrationTarget); menu.Items.Add(migrationItem); } diff --git a/mySQLPunk/SqlServerMigrationForm.cs b/mySQLPunk/SqlServerMigrationForm.cs index 1c5866a..8d87c0b 100644 --- a/mySQLPunk/SqlServerMigrationForm.cs +++ b/mySQLPunk/SqlServerMigrationForm.cs @@ -25,7 +25,7 @@ public sealed class SqlServerMigrationForm : Form public SqlServerMigrationForm(SchemaComparisonEndpoint source, SchemaComparisonEndpoint target) { this.source = source; this.target = target; - Text = "SQL Server 降版相容搬移(有限範圍預覽版)"; + Text = "SQL Server 雙向跨版本相容搬移(有限範圍預覽版)"; Width = 1000; Height = 680; StartPosition = FormStartPosition.CenterParent; version.Items.AddRange(new object[] { "2016 (13)", "2017 (14)", "2019 (15)", "2022 (16)", "2025 (17)" }); var controls = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 76 }; @@ -36,7 +36,7 @@ public SqlServerMigrationForm(SchemaComparisonEndpoint source, SchemaComparisonE var notice = new Label { Dock = DockStyle.Top, Height = 100, Padding = new Padding(8), Text = "來源:" + source.DisplayName + " → 目標:" + target.DisplayName + "\n" + "只接受另建的空資料庫;不建立資料庫、不覆蓋、不自動重試。僅 dbo 一般 heap/白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能全部阻擋。定序必須相同。\n" + - "每次最多 100,000 列;分批寫入同一交易並逐欄內容驗證。2016–2025 目前僅 mock 驗證,尚無真實降版矩陣。來源使用 SERIALIZABLE,可能暫時阻擋其他寫入。" }; + "2016–2025 可雙向及同版本邏輯搬移,不是 .bak 還原。每次最多 100,000 列;分批寫入同一交易並逐欄內容驗證。目前僅 mock 驗證,尚無真實跨版本矩陣。來源使用 SERIALIZABLE,可能暫時阻擋其他寫入。" }; Controls.Add(preview); Controls.Add(controls); Controls.Add(notice); inspect.Click += Inspect; start.Click += Start; diff --git a/mySQLPunk/lib/SqlServerCompatibilityMigration.cs b/mySQLPunk/lib/SqlServerCompatibilityMigration.cs index 4150fa0..ef2bcf4 100644 --- a/mySQLPunk/lib/SqlServerCompatibilityMigration.cs +++ b/mySQLPunk/lib/SqlServerCompatibilityMigration.cs @@ -79,7 +79,6 @@ public static SqlServerMigrationPreview Preview(SqlServerMigrationCatalog source if (!SupportedTargetMajors.Contains(target.MajorVersion) || !SupportedTargetMajors.Contains(source.MajorVersion)) preview.Blockers.Add("僅規劃支援 SQL Server 2016–2025;未知或更早引擎禁止執行。"); if (target.MajorVersion != selectedTargetMajor) preview.Blockers.Add("選取的最低目標版本與實際引擎版本不符。"); - if (source.MajorVersion <= target.MajorVersion) preview.Blockers.Add("此工具只接受新版來源搬至舊版目標。"); if (string.IsNullOrWhiteSpace(source.Identity) || string.IsNullOrWhiteSpace(target.Identity) || source.Identity == target.Identity) preview.Blockers.Add("無法確認來源/目標識別,或兩者為同一資料庫。"); if (target.ObjectCount != 0 || target.Tables.Count != 0) preview.Blockers.Add("目標必須是使用者另建的空資料庫,不允許覆蓋。"); diff --git a/tests/SqlServerMigrationTests.cs b/tests/SqlServerMigrationTests.cs index 2b5ac3e..4b06ad2 100644 --- a/tests/SqlServerMigrationTests.cs +++ b/tests/SqlServerMigrationTests.cs @@ -14,6 +14,16 @@ public static void Run() foreach (string type in new[] { "vector", "xml", "nvarchar(max)", "nvarchar(4001)", "varchar(8001)", "decimal(2,3)", "int);DROP TABLE x--" }) Check(!SqlServerCompatibilityMigration.IsSupportedType(type), "Unsafe type " + type); foreach (int major in SqlServerCompatibilityMigration.SupportedTargetMajors.Where(value => value < 17)) Check(SqlServerCompatibilityMigration.Preview(Source().Catalog, Target(major).Catalog, major).CanStart, "Declared matrix " + major); + foreach (int sourceMajor in SqlServerCompatibilityMigration.SupportedTargetMajors) + foreach (int targetMajor in SqlServerCompatibilityMigration.SupportedTargetMajors) + { + var origin = Source(); origin.Catalog.MajorVersion = sourceMajor; + var destination = Target(targetMajor); + var direction = SqlServerCompatibilityMigration.Preview(origin.Catalog, destination.Catalog, targetMajor); + Check(direction.CanStart, "Bidirectional preflight " + sourceMajor + " -> " + targetMajor); + var migrated = SqlServerCompatibilityMigration.Run(direction, "empty", origin, destination, CancellationToken.None); + Check(migrated.Succeeded && migrated.Rows == 1001 && destination.Commits == 1, "Bidirectional verified transaction " + sourceMajor + " -> " + targetMajor); + } var from = Source(); var to = Target(15); var plan = SqlServerCompatibilityMigration.Preview(from.Catalog, to.Catalog, 15); Check(plan.CanStart && plan.Statements.Single().Contains("[na]]me]"), "Preview quoted DDL"); From 13493b98abceb123de0c04301fc295aa52d51b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 12:10:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=AF?= =?UTF-8?q?=E9=A9=97=E8=AD=89=E7=9A=84=20SQLite=20=E8=88=87=20PostgreSQL?= =?UTF-8?q?=20=E9=82=8F=E8=BC=AF=E6=90=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一般資料傳輸與 SQL 對映無法保證跨引擎結構、內容和重試安全,所以另加受限搬移入口。第一階段只接受 STRICT INTEGER/BLOB 與 bigint/bytea、NULL/NOT NULL;正式 catalog 發現鍵、索引、預設值、觸發器等不支援語意就拒絕,不靜默漏掉物件。 預覽固定來源內容與實際版本,確認完整新空目標後重新預檢,分批寫入單一交易,再校驗型別、結構與內容多重集合。取消等待實際停止;提交結果不確定時禁止盲重試。獨立連線沿用設定工廠及 SSH owner,沒有從已隱去密碼的連線字串重建。 本機 33/33 smoke 與正式 Avalonia handlers 回歸通過,涵蓋真 SQLite 部分寫入回滾、取消、真正提交後 ack 遺失及 UI gate。另加入既有隔離 PostgreSQL 16 CI fixture 的雙向內容及 catalog 回歸;歷史版本與真 SQL Server 跨版本矩陣尚未驗證。 --- CHANGELOG.md | 1 + README.md | 2 + docs/FEATURE_NOTES.md | 3 + docs/LOGICAL_MIGRATION_MATRIX.md | 43 +++ mySQLPunk.Core/Models/LogicalMigration.cs | 17 + .../Providers/AdoDatabaseSession.cs | 25 ++ .../Services/LogicalMigrationService.cs | 359 ++++++++++++++++++ .../LogicalMigrationTests.cs | 153 ++++++++ mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 2 + mySQLPunk.Desktop/LogicalMigrationWindow.cs | 109 ++++++ mySQLPunk.Desktop/MainWindow.axaml | 3 +- mySQLPunk.Desktop/MainWindow.axaml.cs | 11 + .../DesktopProfileOperationHarness/Program.cs | 42 +- 13 files changed, 768 insertions(+), 2 deletions(-) create mode 100644 docs/LOGICAL_MIGRATION_MATRIX.md create mode 100644 mySQLPunk.Core/Models/LogicalMigration.cs create mode 100644 mySQLPunk.Core/Services/LogicalMigrationService.cs create mode 100644 mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs create mode 100644 mySQLPunk.Desktop/LogicalMigrationWindow.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ebaca..c09e2f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### 🚀 新增功能 +- **可驗證邏輯搬移的第一階段**:SQL Server 專用相容工具加入受限雙向跨版本;Avalonia 新增 SQLite STRICT ↔ PostgreSQL 的整數/二進位搬移,預覽來源/目標版本、schema/data 轉換與阻擋原因,確認後才寫入另建空目標。單一交易、分批、內容校驗與不確定狀態禁止重試,不將一般資料傳輸或原生備份宣稱為無限制跨版本/跨引擎還原。 - **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 尚僅離線/mock 驗證,不宣稱真引擎矩陣通過。 diff --git a/README.md b/README.md index 3375d47..bd97d1a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Windows 完整版介面支援繁體中文與英文;資料庫密碼、SSH 密 目前發版版本:`v1.0.0.22`,最新版請看 [GitHub Releases](https://github.com/shadowjohn/mySQLPunk/releases)。 +未發版的可驗證邏輯搬移:Windows SQL Server 專用工具支援 2016–2025 受限雙向跨版本;Avalonia「邏輯搬移」提供 SQLite STRICT ↔ PostgreSQL 的 INTEGER/bigint、BLOB/bytea 子集。只接受另建空目標、明確確認、單一交易與內容校驗;PK/FK、索引、default、identity、字串定序、時間與其他未忠實轉換的功能在預檢阻擋,不是任意引擎/版本的原生 restore。支援方向與實機/mock 證據區分見 [搬移矩陣](docs/LOGICAL_MIGRATION_MATRIX.md)。 + 目前的 `v1.0.0.22` GitHub Release 同時提供 Windows x64 完整版 setup、self-contained Linux x64/ARM64 安裝壓縮檔,以及 macOS Intel/Apple Silicon `.app.zip`。Linux 壓縮檔內附使用者層級的 `install.sh`/`uninstall.sh`;macOS 預覽目前採 ad-hoc 簽署、尚未 Apple notarize。每個跨平台資產都有獨立 `.sha256` 可驗證完整性。完整變更請見 `CHANGELOG.md`。 ## 開發環境 diff --git a/docs/FEATURE_NOTES.md b/docs/FEATURE_NOTES.md index ec54067..0516ae9 100644 --- a/docs/FEATURE_NOTES.md +++ b/docs/FEATURE_NOTES.md @@ -624,3 +624,6 @@ 桌面 gate 等完整 task 停止才釋放,取消結果不寫回 grid。隔離 smoke 實際執行 50,000,000 次 recursive 計算,驗證快速返回、三次取消與後續查詢、一般 SQL 錯誤及已提交 DML 不回滾。Xvfb harness 使用正式 SQL/Cancel handlers 驗證 UI tick、重複點擊、busy edit/delete/connect、取消後查詢。這不是資料同步或所有 metadata 路徑的背景化保證;鎖等待仍受 provider timeout 限制。 +# 可驗證雙向與跨引擎邏輯搬移 + +Windows SQL Server 專用工具加入受限升版/降版/同版本方向;Avalonia 工作台新增 SQLite STRICT 與 PostgreSQL 的 INTEGER/bigint、BLOB/bytea 搬移。兩種工具都只寫入明確確認的新空目標,不把未支援型別、鍵、索引、預設值、trigger/procedure 等靜默丟掉。方向、實際版本、交易/取消/不確定重試及測試矩陣見 [LOGICAL_MIGRATION_MATRIX.md](LOGICAL_MIGRATION_MATRIX.md)。一般資料傳輸與 SQL 文字轉換不等於此處的可驗證邏輯還原。 diff --git a/docs/LOGICAL_MIGRATION_MATRIX.md b/docs/LOGICAL_MIGRATION_MATRIX.md new file mode 100644 index 0000000..3f8ba35 --- /dev/null +++ b/docs/LOGICAL_MIGRATION_MATRIX.md @@ -0,0 +1,43 @@ +# 可驗證邏輯搬移:分階段矩陣 + +邏輯搬移讀取 schema/資料、轉換到目標 DDL、參數化寫入並驗證內容,不是原生 restore。既有一般 DataTransferService 的型別對映及列數 checkpoint、SchemaSyncScriptService 的手動修正 SQL,不具備這個工具的完整預檢與內容保證,沒有直接拿來當跨引擎還原器。 + +## 已落地方向 + +| 來源 → 目標 | 程式允許的版本 | 可執行範圍 | 驗證區分 | +| --- | --- | --- | --- | +| SQL Server → SQL Server | 2016/2017/2019/2022/2025 任意雙向及同版本,共 25 組 | Windows 既有專用工具;dbo heap、既有型別白名單、同定序、無 advanced features | 25 組預檢及交易 mock;不是 25 組真引擎驗證 | +| SQLite → SQLite | 3.37–3.53 | STRICT、INTEGER/BLOB、NULL/NOT NULL | 本機實際 SQLite 3.53.3;歷史 minor 版本未逐一跑 | +| SQLite → PostgreSQL | SQLite 3.37–3.53 → PostgreSQL 14–17 | INTEGER → bigint、BLOB → bytea | 既有隔離 CI PostgreSQL 16 fixture 有雙向內容 round-trip 測試;以該 SHA 的 CI 結果為準 | +| PostgreSQL → SQLite | PostgreSQL 14–17 → SQLite 3.37–3.53 | bigint → INTEGER、bytea → BLOB | 同上;14/15/17 未實機跑 | +| PostgreSQL → PostgreSQL | 14–17 雙向及同版本 | 同一 bigint/bytea 子集與全部預檢 | 邏輯共用路徑;沒有完整跨版本真機矩陣 | + +SQLite/PostgreSQL 工具入口在 Avalonia 工作台「邏輯搬移」。來源使用目前連線的記憶體設定;目標選已儲存的連線及另建空資料庫,明確選版本,必要密碼只由使用者輸入,不額外讀秘密庫。獨立 provider owner 保有 SSH tunnel,所有開啟/失敗/結束都有 scope 釋放;不從 redacted ConnectionString 重建。 + +## 寫入前安全檢查 + +- SQLite 檔案必須已存在;來源唯讀開啟,目標 ReadWrite(不自動建立),使用 private cache、停用 pooling。缺檔、非 UTF-8、非 STRICT、virtual table、生成欄位、default、check、PK/FK、index、trigger、view、內部 sequence 等一律拒絕。 +- SQLite CREATE TABLE 只接受可證明完整的 INTEGER/BLOB STRICT grammar;不是從欄位猜 DDL 後丟掉未辨識 clause。PostgreSQL 查正式 catalog:額外 schema、extension、view、sequence、constraint、index、default、identity、trigger(含 database event trigger)、function/procedure、rule、policy、user type、partition、inheritance、RLS、unlogged、large object、publication/subscription、自訂定序/轉換/運算子/文字搜尋物件等都阻擋。查詢失敗也不放行;不自動搬移帳號、權限或安全設定。 +- 名稱限制 ASCII identifier 63 字元;轉到 SQLite 另阻擋只差大小寫的表/欄位。文字 encoding/collation、時間與浮點/decimal precision、identity/sequence 的忠實轉換尚未實作,因此不是靜默轉成 TEXT 或捨棄鍵/預設值。 +- 預覽顯示實際版本、方向、目標、表、列數、轉換 SQL 與阻擋原因,不顯示資料值。來源內容也納入確認 fingerprint;輸入完整目標名稱才可執行,確認後來源內容(包括相同列數下的變更)/結構/實際引擎識別/目標變動會拒絕。 +- SQLite/PostgreSQL 全部 DDL+資料在單一目標交易。SQLite 用 immediate 交易;PostgreSQL 持有工具專用 advisory lock,來源全部表 SHARE lock 後讀取並在提交前再檢查。這不是在線無鎖搬移;請用隔離副本/維護時段,外部工具不會自動遵守 advisory lock,也不能宣稱能禁止其他管理者在最後檢查之後建立額外物件。 +- 每批至多 100 列/200 參數,最多 100,000 列;每欄至多 1 MiB,每輪快照資料至多 64 MiB。驗證 schema、列數、Int64/binary/NULL 的 SHA-256 多重集合(保留重複列)。不保證隱含 rowid、實體排序或儲存配置跨引擎相同。 +- 取消等實際工作停止,SQLite 用原生 progress handler,PostgreSQL 使用 provider cancellation;工作台 gate 持續到對話框關閉,不能同時 SQL/edit/delete/connect。變更目標/版本/認證即失效原預覽。 + +## 重試與恢復 + +沒有逐表 checkpoint:全部驗證後才提交。失敗且確認回滾的結果是 `RolledBack`,須重新預檢;已驗證提交是 `Verified`,不再寫入。commit 開始後失敗、回覆遺失或回滾失敗是 `Uncertain`,UI 停止再次預檢/寫入,禁止盲目重試,必須由 DBA 核對;不宣稱放棄 Task 就已停止或回滾。已有內容的目標即使是前次結果也不能自動 resume。 + +## 尚未實作、執行前拒絕 + +MySQL/MariaDB、SQL Server 與 SQLite/PostgreSQL 之間的通用 verified conversion 尚未接入;MySQL 目標的 implicit-commit DDL 不能沿用此單一交易保證,需另一套可驗證的 staging/恢復設計。Windows Oracle/Snowflake 也未加入此 verified 路徑。MongoDB、Redis/Garnet 等非關聯式 model 沒有通用無損表格映射,不能當同義還原目標。選不支援的 provider,服務在開連線前明確拒絕。 + +有 PK/FK、索引、字串定序、時間、decimal、identity、procedure/trigger 的一般資料庫不能在此階段搬移;替代路徑是引擎官方 schema/data migration 工具與人工轉換/驗證,不是宣稱 bacpac 可無限制降版。新版 SQL Server .bak 仍不能直接還原到舊引擎,compatibility level 不改備份格式;既有原生 restore 預檢繼續保留。 + +## 回歸證據 + +實際隔離 SQLite:1,005 列、多批、Int64 極值、空/非空 bytes、NULL、重複列、反向內容一致、錯誤確認、缺目標不自動建立、來源同列數內容改變、unsupported features、部分寫入失敗、內容/結構驗證失敗、途中取消,以及真正 commit 後注入 ack lost(保留資料且禁止重試)。不是單靠手動設 mock Blockers。 + +Avalonia/Xvfb:正式入口持有主視窗 gate、預覽、目標變更失效、錯誤確認不寫入、實際確認寫入且禁止重複 resume。既有跨平台 CI fixture 另測 SQLite ↔ PostgreSQL 16,包括正式 catalog 逐項加 feature/移除 feature 後恢復可預檢。 + +真 SQL Server 跨版本、SQL auth+SSH 真機、PostgreSQL 14/15/17、SQLite 歷史版本及非關聯式轉換皆沒有完整矩陣證據;不得把 mock/同版本一般 provider round-trip 當全部資料庫版本已通過。 diff --git a/mySQLPunk.Core/Models/LogicalMigration.cs b/mySQLPunk.Core/Models/LogicalMigration.cs new file mode 100644 index 0000000..a196ef1 --- /dev/null +++ b/mySQLPunk.Core/Models/LogicalMigration.cs @@ -0,0 +1,17 @@ +namespace MySqlPunk.Core.Models; + +public sealed record LogicalMigrationColumn(string Name, string Kind, bool Nullable); +public sealed record LogicalMigrationTable(string Name, IReadOnlyList Columns, long Rows, string ContentHash); +public sealed record LogicalMigrationPreview( + string SourceVersion, string TargetVersion, string TargetDatabase, + IReadOnlyList Tables, IReadOnlyList Statements, + IReadOnlyList Blockers, string Fingerprint) +{ + public bool CanStart => Blockers.Count == 0 && Tables.Count > 0; +} + +public enum LogicalMigrationOutcome { Verified, RolledBack, Uncertain, Refused } +public sealed record LogicalMigrationResult(LogicalMigrationOutcome Outcome, long Rows, string Report) +{ + public bool RetryBlocked => Outcome is LogicalMigrationOutcome.Uncertain or LogicalMigrationOutcome.Verified; +} diff --git a/mySQLPunk.Core/Providers/AdoDatabaseSession.cs b/mySQLPunk.Core/Providers/AdoDatabaseSession.cs index 14c6480..5668dae 100644 --- a/mySQLPunk.Core/Providers/AdoDatabaseSession.cs +++ b/mySQLPunk.Core/Providers/AdoDatabaseSession.cs @@ -33,6 +33,31 @@ protected AdoDatabaseSession(ConnectionProfile profile) protected abstract DbConnection CreateConnection(string? database); + internal async Task OpenMigrationConnectionAsync(string database, CancellationToken cancellationToken, bool readOnly = false) + { + var connection = await CreateConnectionAsync(database, cancellationToken).ConfigureAwait(false); + try + { + if (connection is Microsoft.Data.Sqlite.SqliteConnection sqlite) + { + var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(sqlite.ConnectionString) + { + Mode = readOnly ? Microsoft.Data.Sqlite.SqliteOpenMode.ReadOnly : Microsoft.Data.Sqlite.SqliteOpenMode.ReadWrite, + Cache = Microsoft.Data.Sqlite.SqliteCacheMode.Private, + Pooling = false + }; + sqlite.ConnectionString = builder.ConnectionString; + } + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return connection; + } + catch + { + await connection.DisposeAsync().ConfigureAwait(false); + throw; + } + } + /// /// Creates a closed connection after making sure the SSH tunnel (if the profile enables one) is up. All /// database work goes through here so a tunnelled profile can never fall back to dialling the host directly. diff --git a/mySQLPunk.Core/Services/LogicalMigrationService.cs b/mySQLPunk.Core/Services/LogicalMigrationService.cs new file mode 100644 index 0000000..696722d --- /dev/null +++ b/mySQLPunk.Core/Services/LogicalMigrationService.cs @@ -0,0 +1,359 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; +using MySqlPunk.Core.Models; +using MySqlPunk.Core.Providers; + +namespace MySqlPunk.Core.Services; + +public static class LogicalMigrationService +{ + public const int MaximumRows = 100_000; + 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 Tables, + Dictionary> Data, List Blockers); + + public static Task PreviewAsync(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, CancellationToken cancellationToken = default) => + Task.Run(() => PreviewCoreAsync(source.Clone(), target.Clone(), selectedTargetMajor, cancellationToken), cancellationToken); + + public static Task RunAsync(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, LogicalMigrationPreview approved, string confirmation, + CancellationToken cancellationToken = default) => + Task.Run(() => RunCoreAsync(source.Clone(), target.Clone(), selectedTargetMajor, approved, confirmation, cancellationToken, null), cancellationToken); + + internal static Task RunWithObserverAsync(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, LogicalMigrationPreview approved, string confirmation, + CancellationToken token, Action observer) => + Task.Run(() => RunCoreAsync(source.Clone(), target.Clone(), selectedTargetMajor, approved, confirmation, token, observer), token); + + private static bool Supported(ConnectionProfile profile) => + profile.Provider is DatabaseProviderKind.Sqlite or DatabaseProviderKind.PostgreSql; + + private static LogicalMigrationPreview Refused(ConnectionProfile target, string reason) => + new("未識別", "未識別", target.Database, Array.Empty(), Array.Empty(), new[] { reason }, ""); + + private static async Task PreviewCoreAsync(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, CancellationToken token) + { + if (!Supported(source) || !Supported(target)) + return Refused(target, "此階段只實作 SQLite STRICT 與 PostgreSQL 的整數/二進位邏輯搬移。其他引擎請使用專用搬移器;不可當成原生 restore。"); + using var sourceOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(source); + using var targetOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(target); + await using var origin = await sourceOwner.OpenMigrationConnectionAsync(source.Database, token, readOnly: true).ConfigureAwait(false); + await using var destination = await targetOwner.OpenMigrationConnectionAsync(target.Database, token).ConfigureAwait(false); + using var sourceProgress = new SqliteProgress(origin, token); + using var targetProgress = new SqliteProgress(destination, token); + await using var sourceTransaction = await origin.BeginTransactionAsync(source.Provider == DatabaseProviderKind.Sqlite ? IsolationLevel.Serializable : IsolationLevel.ReadCommitted, token).ConfigureAwait(false); + try + { + var sourceSnapshot = await ReadSnapshotAsync(origin, sourceTransaction, source.Provider, true, token).ConfigureAwait(false); + var targetSnapshot = await ReadSnapshotAsync(destination, null, target.Provider, false, token).ConfigureAwait(false); + return BuildPreview(source, target, selectedTargetMajor, sourceSnapshot, targetSnapshot); + } + finally { sourceProgress.Stop(); targetProgress.Stop(); } + } + + private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, Snapshot origin, Snapshot destination) + { + var blockers = origin.Blockers.Select(reason => "來源:" + reason) + .Concat(destination.Blockers.Select(reason => "目標:" + reason)).ToList(); + 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("實際引擎識別為同一資料庫,不允許搬移至自身。"); + 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 (Major(destination.Version) != selectedTargetMajor) blockers.Add("明確選取的目標版本與實際引擎不符。"); + if (source.Provider == target.Provider && source.Database == target.Database && + (source.Provider == DatabaseProviderKind.Sqlite || source.Host == target.Host && source.Port == target.Port)) + blockers.Add("來源與目標不得為同一資料庫。"); + var statements = origin.Tables.Select(table => CreateTableSql(target.Provider, table)).ToArray(); + var fingerprint = Hash(JsonSerializer.Serialize(new + { + 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 + })); + return new(origin.Version, destination.Version, target.Database, origin.Tables.AsReadOnly(), statements, + blockers.AsReadOnly(), fingerprint); + } + + private static async Task RunCoreAsync(ConnectionProfile source, ConnectionProfile target, + int selectedTargetMajor, LogicalMigrationPreview approved, string confirmation, CancellationToken token, + Action? observer) + { + if (!approved.CanStart || confirmation != target.Database || !Supported(source) || !Supported(target)) + return new(LogicalMigrationOutcome.Refused, 0, "未確認完整目標名稱或預檢未通過;沒有寫入。"); + using var sourceOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(source); + using var targetOwner = (AdoDatabaseSession)DatabaseProviderFactory.Create(target); + await using var origin = await sourceOwner.OpenMigrationConnectionAsync(source.Database, token, readOnly: true).ConfigureAwait(false); + await using var destination = await targetOwner.OpenMigrationConnectionAsync(target.Database, token).ConfigureAwait(false); + using var sourceProgress = new SqliteProgress(origin, token); + using var targetProgress = new SqliteProgress(destination, token); + await using var sourceTransaction = await origin.BeginTransactionAsync(source.Provider == DatabaseProviderKind.Sqlite ? IsolationLevel.Serializable : IsolationLevel.ReadCommitted, token).ConfigureAwait(false); + await using var targetTransaction = destination is SqliteConnection sqlite + ? sqlite.BeginTransaction(IsolationLevel.Serializable, deferred: false) + : await destination.BeginTransactionAsync(IsolationLevel.ReadCommitted, token).ConfigureAwait(false); + bool commitAttempted = false; + long rows = 0; + string stage = "交易內重新預檢"; + try + { + if (target.Provider == DatabaseProviderKind.PostgreSql) + await ExecuteAsync(destination, targetTransaction, "SELECT pg_advisory_xact_lock(1836281931);", token).ConfigureAwait(false); + var sourceSnapshot = await ReadSnapshotAsync(origin, sourceTransaction, source.Provider, true, token).ConfigureAwait(false); + var targetSnapshot = await ReadSnapshotAsync(destination, targetTransaction, target.Provider, false, token).ConfigureAwait(false); + var current = BuildPreview(source, target, selectedTargetMajor, sourceSnapshot, targetSnapshot); + if (!current.CanStart || current.Fingerprint != approved.Fingerprint) + throw new InvalidOperationException("來源內容/結構/版本或目標在確認後改變,請重新預檢。"); + for (var tableIndex = 0; tableIndex < sourceSnapshot.Tables.Count; tableIndex++) + { + var table = sourceSnapshot.Tables[tableIndex]; + stage = "建立與傳輸 " + table.Name; + await ExecuteAsync(destination, targetTransaction, current.Statements[tableIndex], token).ConfigureAwait(false); + var data = sourceSnapshot.Data[table.Name]; + var batchSize = Math.Max(1, Math.Min(100, 200 / table.Columns.Count)); + foreach (var batch in data.Chunk(batchSize)) + { + token.ThrowIfCancellationRequested(); + await using var command = destination.CreateCommand(); + command.Transaction = targetTransaction; + var values = new List(); + foreach (var row in batch) + { + var parameters = new List(); + for (var columnIndex = 0; columnIndex < row.Length; columnIndex++) + { + var name = "@value" + command.Parameters.Count; + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = table.Columns[columnIndex].Kind == "integer" ? DbType.Int64 : DbType.Binary; + parameter.Value = row[columnIndex] ?? DBNull.Value; + command.Parameters.Add(parameter); + parameters.Add(name); + } + values.Add("(" + string.Join(",", parameters) + ")"); + } + command.CommandText = "INSERT INTO " + Qualified(target.Provider, table.Name) + " (" + + string.Join(",", table.Columns.Select(column => Quote(column.Name))) + ") VALUES " + string.Join(",", values); + await command.ExecuteNonQueryAsync(token).ConfigureAwait(false); + rows += batch.Length; + observer?.Invoke("batch", destination, targetTransaction); + } + } + 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)) + 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)) + throw new InvalidOperationException("來源結構/內容在搬移期間改變。"); + token.ThrowIfCancellationRequested(); + sourceProgress.Stop(); + await sourceTransaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + stage = "提交目標"; + targetProgress.Stop(); + token.ThrowIfCancellationRequested(); + commitAttempted = true; + await targetTransaction.CommitAsync(CancellationToken.None).ConfigureAwait(false); + observer?.Invoke("committed", destination, targetTransaction); + return new(LogicalMigrationOutcome.Verified, rows, "單一交易已提交;結構、列數與逐欄型別/NULL/內容 SHA-256 多重集合全部相符。不自動再次寫入。"); + } + catch (Exception exception) + { + sourceProgress.Stop(); targetProgress.Stop(); + bool rollbackConfirmed = !commitAttempted; + try { await targetTransaction.RollbackAsync(CancellationToken.None).ConfigureAwait(false); } + catch { rollbackConfirmed = false; } + return new(rollbackConfirmed ? LogicalMigrationOutcome.RolledBack : LogicalMigrationOutcome.Uncertain, rows, + (token.IsCancellationRequested ? "已取消。" : "搬移失敗。") + "階段:" + stage + ";" + exception.GetType().Name + + (rollbackConfirmed ? ";目標交易已回滾。重新預檢後才能重試,沒有逐表 checkpoint 續寫。" : ";提交/清理結果不確定,禁止盲目重試,請先由 DBA 核對目標。")); + } + } + + private static async Task ReadSnapshotAsync(DbConnection connection, DbTransaction? transaction, + DatabaseProviderKind provider, bool readData, CancellationToken token) + { + var blockers = new List(); + 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) : + Convert.ToString(await ScalarAsync(connection, transaction, + "SELECT current_database() || ':' || (SELECT oid::text FROM pg_database WHERE datname=current_database()) || ':' || coalesce(inet_server_addr()::text,'local') || ':' || coalesce(inet_server_port()::text,'local');", token)) ?? ""; + if (identity.Length == 0) blockers.Add("無法確認實際資料庫識別。"); + if (provider == DatabaseProviderKind.Sqlite) + { + if (!Version.TryParse(version.Split(' ')[0], out var parsed) || parsed.Major != 3 || parsed.Minor < 37 || parsed.Minor > 53) + blockers.Add("只允許 SQLite 3.37–3.53;其他版本尚未建立能力矩陣。"); + var encoding = await ScalarAsync(connection, transaction, "PRAGMA encoding;", token); + if (!Equals(encoding, "UTF-8")) blockers.Add("只允許 UTF-8 SQLite 資料庫。"); + } + else + { + if (Major(version) is < 14 or > 17) blockers.Add("只允許 PostgreSQL 14–17;其他版本尚未建立能力矩陣。"); + if (!Equals(await ScalarAsync(connection, transaction, "SHOW server_encoding;", token), "UTF8")) blockers.Add("只允許 UTF8 PostgreSQL 資料庫。"); + if (Convert.ToInt64(await ScalarAsync(connection, transaction, PostgreSqlUnsupportedCatalogSql, token)) != 0) + blockers.Add("發現不支援的 schema/extension/view/sequence/index/constraint/default/identity/trigger/procedure/type/partition/RLS/inheritance 或表屬性;不能靜默略過。"); + } + var definitions = new List<(string Name, string Definition)>(); + await using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = provider == DatabaseProviderKind.Sqlite + ? "SELECT name, type, coalesce(sql,'') FROM sqlite_schema ORDER BY name;" + : "SELECT c.relname, 'table', '' FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relkind='r' ORDER BY c.relname COLLATE \"C\";"; + await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false); + while (await reader.ReadAsync(token).ConfigureAwait(false)) + { + var name = reader.GetString(0); + if (reader.GetString(1) != "table" || name.StartsWith("sqlite_", StringComparison.Ordinal)) + blockers.Add("不支援物件:" + name + "(" + reader.GetString(1) + ")"); + else definitions.Add((name, reader.GetString(2))); + } + } + var tables = new List(); + var data = new Dictionary>(); + long totalRows = 0, totalBytes = 0; + if (readData && provider == DatabaseProviderKind.PostgreSql && blockers.Count == 0) + foreach (var definition in definitions) + await ExecuteAsync(connection, transaction, "LOCK TABLE " + Qualified(provider, definition.Name) + " IN SHARE MODE;", token).ConfigureAwait(false); + foreach (var definition in definitions) + { + token.ThrowIfCancellationRequested(); + if (!Identifier.IsMatch(definition.Name)) { blockers.Add("表名不在 ASCII 63 字元安全子集:" + definition.Name); continue; } + var columns = await ReadColumnsAsync(connection, transaction, provider, definition.Name, blockers, token).ConfigureAwait(false); + if (columns.Count == 0 || columns.Count > 100) { blockers.Add("欄位數必須為 1–100:" + definition.Name); continue; } + if (provider == DatabaseProviderKind.Sqlite && !SafeSqliteDefinition(definition.Name, columns, definition.Definition)) + blockers.Add("僅接受無鍵/索引/default/collation/生成欄位的 INTEGER、BLOB STRICT 表:" + definition.Name); + var rows = new List(); + if (readData && blockers.Count == 0) + { + await using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = "SELECT " + string.Join(",", columns.Select(column => Quote(column.Name))) + " FROM " + Qualified(provider, definition.Name); + await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false); + while (await reader.ReadAsync(token).ConfigureAwait(false)) + { + if (++totalRows > MaximumRows) throw new InvalidOperationException("來源超過 100,000 列,請使用專用搬移器。"); + var row = new object?[columns.Count]; + for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) + { + var value = reader.IsDBNull(columnIndex) ? null : reader.GetValue(columnIndex); + if (value != null && (columns[columnIndex].Kind == "integer" ? value is not long : value is not byte[])) + throw new InvalidOperationException("實際儲存型別與宣告不符。"); + var bytes = value is byte[] binary ? binary.Length : 8; + totalBytes += bytes; + if (bytes > 1024 * 1024 || totalBytes > MaximumBytes) throw new InvalidOperationException("超過單欄 1 MiB/總量 64 MiB 預覽上限。"); + row[columnIndex] = value; + } + rows.Add(row); + } + } + tables.Add(new(definition.Name, columns.AsReadOnly(), rows.Count, ContentHash(rows))); + data.Add(definition.Name, rows); + } + return new(version, identity, tables, data, blockers); + } + + internal const string PostgreSqlUnsupportedCatalogSql = """ + SELECT + (SELECT count(*) FROM pg_namespace WHERE nspname NOT IN ('public','information_schema','pg_catalog') AND nspname NOT LIKE 'pg_toast%' AND nspname NOT LIKE 'pg_temp_%') + + (SELECT count(*) FROM pg_extension WHERE extname <> 'plpgsql') + + (SELECT count(*) FROM pg_event_trigger) + + (SELECT count(*) FROM pg_largeobject_metadata) + + (SELECT count(*) FROM pg_publication) + + (SELECT count(*) FROM pg_subscription WHERE subdbid=(SELECT oid FROM pg_database WHERE datname=current_database())) + + (SELECT count(*) FROM pg_collation c JOIN pg_namespace n ON n.oid=c.collnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_conversion c JOIN pg_namespace n ON n.oid=c.connamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_operator o JOIN pg_namespace n ON n.oid=o.oprnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_opclass o JOIN pg_namespace n ON n.oid=o.opcnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_opfamily o JOIN pg_namespace n ON n.oid=o.opfnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_ts_config c JOIN pg_namespace n ON n.oid=c.cfgnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_ts_dict d JOIN pg_namespace n ON n.oid=d.dictnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_type t JOIN pg_namespace n ON n.oid=t.typnamespace WHERE n.nspname='public' AND t.typtype NOT IN ('c') AND NOT (t.typcategory='A' AND EXISTS (SELECT 1 FROM pg_type base JOIN pg_class c ON c.oid=base.typrelid WHERE base.oid=t.typelem AND c.relkind='r'))) + + (SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND (c.relkind<>'r' OR c.relpersistence<>'p' OR c.relispartition OR c.relrowsecurity OR c.relforcerowsecurity OR c.reloptions IS NOT NULL OR c.reltablespace<>0)) + + (SELECT count(*) FROM pg_constraint c JOIN pg_namespace n ON n.oid=c.connamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_attrdef a JOIN pg_class c ON c.oid=a.adrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_inherits i JOIN pg_class c ON c.oid=i.inhrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public') + + (SELECT count(*) FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND a.attnum>0 AND (a.attisdropped OR a.attidentity<>'' OR a.attgenerated<>'' OR a.attcollation<>0 OR a.atttypid NOT IN (20,17))); + """; + + private static async Task> ReadColumnsAsync(DbConnection connection, DbTransaction? transaction, + DatabaseProviderKind provider, string table, List blockers, CancellationToken token) + { + var columns = new List(); + await using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = provider == DatabaseProviderKind.Sqlite ? "PRAGMA table_xinfo(" + Quote(table) + ");" + : "SELECT a.attname, format_type(a.atttypid,a.atttypmod), a.attnotnull FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=@table AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum;"; + if (provider != DatabaseProviderKind.Sqlite) { var parameter = command.CreateParameter(); parameter.ParameterName = "@table"; parameter.Value = table; command.Parameters.Add(parameter); } + await using var reader = await command.ExecuteReaderAsync(token).ConfigureAwait(false); + while (await reader.ReadAsync(token).ConfigureAwait(false)) + { + var name = reader.GetString(provider == DatabaseProviderKind.Sqlite ? 1 : 0); + var type = reader.GetString(provider == DatabaseProviderKind.Sqlite ? 2 : 1).ToLowerInvariant(); + var kind = type is "integer" or "bigint" ? "integer" : type is "blob" or "bytea" ? "binary" : "unsupported"; + if (!Identifier.IsMatch(name) || kind == "unsupported") blockers.Add("不支援欄位名稱/型別:" + table + "." + name + " " + type); + var notNull = provider == DatabaseProviderKind.Sqlite ? reader.GetInt64(3) != 0 : reader.GetBoolean(2); + columns.Add(new(name, kind, !notNull)); + } + return columns; + } + + private static bool SafeSqliteDefinition(string name, List columns, string sql) + { + static string NamePattern(string identifier) => "(?:" + Regex.Escape(identifier) + "|\"" + Regex.Escape(identifier) + "\")"; + var definitions = columns.Select(column => NamePattern(column.Name) + "\\s+" + + (column.Kind == "integer" ? "INTEGER" : "BLOB") + (column.Nullable ? "" : "\\s+NOT\\s+NULL")); + return Regex.IsMatch(sql, "\\ACREATE\\s+TABLE\\s+" + NamePattern(name) + "\\s*\\(\\s*" + + string.Join("\\s*,\\s*", definitions) + "\\s*\\)\\s+STRICT\\s*;?\\s*\\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static string CreateTableSql(DatabaseProviderKind provider, LogicalMigrationTable table) => + "CREATE TABLE " + Qualified(provider, table.Name) + " (" + string.Join(",", table.Columns.Select(column => + Quote(column.Name) + " " + (column.Kind == "integer" ? provider == DatabaseProviderKind.Sqlite ? "INTEGER" : "bigint" + : provider == DatabaseProviderKind.Sqlite ? "BLOB" : "bytea") + (column.Nullable ? "" : " NOT NULL"))) + + ")" + (provider == DatabaseProviderKind.Sqlite ? " STRICT;" : ";"); + private static int Major(string version) => int.TryParse(version.Split('.')[0], out var major) ? major : -1; + private static string Quote(string name) => "\"" + name.Replace("\"", "\"\"") + "\""; + private static string Qualified(DatabaseProviderKind provider, string table) => (provider == DatabaseProviderKind.PostgreSql ? "\"public\"." : "") + Quote(table); + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + private static string ContentHash(List rows) => Hash(string.Join("\n", rows.Select(row => + Hash(string.Join("|", row.Select(value => value == null ? "null" : value is byte[] binary ? "binary:" + Convert.ToBase64String(binary) + : "integer:" + ((long)value).ToString(CultureInfo.InvariantCulture))))).OrderBy(value => value, StringComparer.Ordinal))); + private static async Task ScalarAsync(DbConnection connection, DbTransaction? transaction, string sql, CancellationToken token) + { + await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = sql; + return await command.ExecuteScalarAsync(token).ConfigureAwait(false); + } + private static async Task ExecuteAsync(DbConnection connection, DbTransaction? transaction, string sql, CancellationToken token) + { + await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = sql; + await command.ExecuteNonQueryAsync(token).ConfigureAwait(false); + } + private sealed class SqliteProgress : IDisposable + { + private readonly SqliteConnection? _connection; + public SqliteProgress(DbConnection connection, CancellationToken token) + { + _connection = connection as SqliteConnection; + if (_connection != null) SQLitePCL.raw.sqlite3_progress_handler(_connection.Handle!, 1000, _ => token.IsCancellationRequested ? 1 : 0, null); + } + public void Stop() + { + if (_connection != null) SQLitePCL.raw.sqlite3_progress_handler(_connection.Handle!, 0, null, null); + } + public void Dispose() => Stop(); + } +} diff --git a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs new file mode 100644 index 0000000..fbba9e5 --- /dev/null +++ b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs @@ -0,0 +1,153 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using MySqlPunk.Core.Models; +using MySqlPunk.Core.Providers; +using MySqlPunk.Core.Services; + +internal static class LogicalMigrationTests +{ + public static async Task RunAsync() + { + var root = Path.Combine(Path.GetTempPath(), "mysqlpunk-logical-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var source = Profile(Path.Combine(root, "source.db")); + var target = Profile(Path.Combine(root, "target.db")); + CreateEmpty(target.Database); + using var session = DatabaseProviderFactory.Create(source); + await session.ExecuteAsync(source.Database, "CREATE TABLE sample (number INTEGER, payload BLOB NOT NULL) STRICT;"); + await session.ExecuteAsync(source.Database, "WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM numbers WHERE n<1001) INSERT INTO sample SELECT n, X'00FF01' FROM numbers;"); + await session.ExecuteAsync(source.Database, "INSERT INTO sample VALUES (NULL,X''),(-9223372036854775808,X'FF'),(9223372036854775807,X'00'),(1,X'00FF01');"); + var preview = await LogicalMigrationService.PreviewAsync(source, target, 3); + Check(preview.CanStart && preview.Tables.Single().Rows == 1005 && preview.Statements.Single().EndsWith("STRICT;"), "Real SQLite schema/data preview " + preview.SourceVersion + ": " + string.Join(";", preview.Blockers)); + var denied = await LogicalMigrationService.RunAsync(source, target, 3, preview, "wrong"); + Check(denied.Outcome == LogicalMigrationOutcome.Refused && await ObjectCountAsync(target) == 0, "Confirmation before writing"); + foreach (var fault in new[] { "write", "content", "schema", "cancel", "commit_ack_lost" }) + { + using var cancellation = new CancellationTokenSource(); + var result = await LogicalMigrationService.RunWithObserverAsync(source, target, 3, preview, target.Database, + cancellation.Token, (stage, connection, transaction) => + { + if (fault == "write" && stage == "batch") throw new InvalidOperationException("injected write failure"); + if (fault == "cancel" && stage == "batch") cancellation.Cancel(); + if (fault == "commit_ack_lost" && stage == "committed") throw new InvalidOperationException("injected lost acknowledgement"); + if (stage != "verify" || fault is not ("content" or "schema")) return; + using var command = connection.CreateCommand(); command.Transaction = transaction; + command.CommandText = fault == "content" ? "UPDATE sample SET number=42;" : "CREATE TABLE unexpected (id INTEGER) STRICT;"; + command.ExecuteNonQuery(); + }); + Check(result.Outcome == (fault == "commit_ack_lost" ? LogicalMigrationOutcome.Uncertain : LogicalMigrationOutcome.RolledBack), "Real transaction fault " + fault); + if (fault == "commit_ack_lost") + { + Check(result.RetryBlocked && await ObjectCountAsync(target) == 1, "Lost acknowledgement after actual commit forbids retry"); + Check(!(await LogicalMigrationService.PreviewAsync(source, target, 3)).CanStart, "Committed target cannot resume or duplicate rows"); + target = Profile(Path.Combine(root, "fresh.db")); CreateEmpty(target.Database); + preview = await LogicalMigrationService.PreviewAsync(source, target, 3); + } + else Check(!result.RetryBlocked && await ObjectCountAsync(target) == 0, "Partial writes and DDL actually rolled back " + fault); + } + await session.ExecuteAsync(source.Database, "UPDATE sample SET number=44 WHERE number=1;"); + var changed = await LogicalMigrationService.RunAsync(source, target, 3, preview, target.Database); + Check(changed.Outcome == LogicalMigrationOutcome.RolledBack && await ObjectCountAsync(target) == 0, "Same row count but changed content invalidates approval"); + preview = await LogicalMigrationService.PreviewAsync(source, target, 3); + var success = await LogicalMigrationService.RunAsync(source, target, 3, preview, target.Database); + Check(success.Outcome == LogicalMigrationOutcome.Verified && success.Rows == 1005 && success.RetryBlocked, "Verified actual SQLite commit"); + var reverse = Profile(Path.Combine(root, "reverse.db")); CreateEmpty(reverse.Database); + var reversePreview = await LogicalMigrationService.PreviewAsync(target, reverse, 3); + var reverseResult = await LogicalMigrationService.RunAsync(target, reverse, 3, reversePreview, reverse.Database); + Check(reverseResult.Outcome == LogicalMigrationOutcome.Verified && reversePreview.Tables.Single().ContentHash == preview.Tables.Single().ContentHash, "Reverse direction preserves Int64 limits, bytes, NULL and duplicates"); + Check(!(await LogicalMigrationService.PreviewAsync(source, reverse, 17)).CanStart, "Selected version mismatch denied"); + var missing = Profile(Path.Combine(root, "missing.db")); + try { await LogicalMigrationService.PreviewAsync(source, missing, 3); throw new Exception("Missing target must be denied"); } + catch (SqliteException) { Check(!File.Exists(missing.Database), "No automatic creation of missing target"); } + var unsupported = source.Clone(); unsupported.Provider = DatabaseProviderKind.MySql; + Check(!(await LogicalMigrationService.PreviewAsync(unsupported, reverse, 3)).CanStart, "Unsupported engine refused without opening a network connection"); + foreach (var definition in new[] + { + "CREATE TABLE bad (id INTEGER PRIMARY KEY, payload BLOB) STRICT;", + "CREATE TABLE bad (id INTEGER DEFAULT 1, payload BLOB) STRICT;", + "CREATE TABLE bad (id INTEGER CHECK(id>0), payload BLOB) STRICT;", + "CREATE TABLE bad (id REAL, payload BLOB) STRICT;", + "CREATE TABLE bad (id TEXT COLLATE NOCASE, payload BLOB) STRICT;", + "CREATE TABLE bad (id INTEGER, payload BLOB);", + "CREATE TABLE bad (id INTEGER REFERENCES sample(number), payload BLOB) STRICT;", + "CREATE TABLE bad (id INTEGER, payload BLOB) STRICT; CREATE INDEX bad_index ON bad(id);", + "CREATE TABLE bad (id INTEGER, payload BLOB) STRICT; CREATE TRIGGER bad_trigger AFTER INSERT ON bad BEGIN SELECT 1; END;", + "CREATE VIEW bad AS SELECT 1;" + }) + { + var blocked = Profile(Path.Combine(root, "blocked-" + Guid.NewGuid().ToString("N") + ".db")); + using var owner = DatabaseProviderFactory.Create(blocked); await owner.ExecuteAsync(blocked.Database, definition); + var empty = Profile(Path.Combine(root, "empty-" + Guid.NewGuid().ToString("N") + ".db")); CreateEmpty(empty.Database); + Check(!(await LogicalMigrationService.PreviewAsync(blocked, empty, 3)).CanStart && await ObjectCountAsync(empty) == 0, "Catalog blocks unsupported SQLite semantics"); + } + } + finally { Directory.Delete(root, true); } + } + + public static async Task PostgreSqlLiveAsync() + { + if (Environment.GetEnvironmentVariable("MYSQLPUNK_LIVE_TESTS") != "1") throw new InvalidOperationException("Only existing isolated live fixtures are allowed."); + var root = Path.Combine(Path.GetTempPath(), "mysqlpunk-logical-pg-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + var postgres = new ConnectionProfile + { + Provider = DatabaseProviderKind.PostgreSql, Name = "Isolated logical migration", Host = Required("MYSQLPUNK_POSTGRES_HOST"), + Port = int.Parse(Required("MYSQLPUNK_POSTGRES_PORT")), Username = Environment.GetEnvironmentVariable("MYSQLPUNK_POSTGRES_USER") ?? "postgres", + Password = Required("MYSQLPUNK_POSTGRES_PASSWORD"), Database = "mysqlpunk_migration_" + Guid.NewGuid().ToString("N"), TlsMode = ConnectionTlsMode.Disabled + }; + using var admin = DatabaseProviderFactory.Create(postgres); + bool created = false; + try + { + await admin.ExecuteAsync("postgres", "CREATE DATABASE \"" + postgres.Database + "\";"); created = true; + 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');"); + var forward = await LogicalMigrationService.PreviewAsync(sqlite, postgres, 16); + Check(forward.CanStart, "Real PostgreSQL catalog allows restricted empty target: " + string.Join(";", forward.Blockers)); + Check((await LogicalMigrationService.RunAsync(sqlite, postgres, 16, forward, postgres.Database)).Outcome == LogicalMigrationOutcome.Verified, "SQLite -> PostgreSQL real typed transfer"); + var reverse = Profile(Path.Combine(root, "target.db")); CreateEmpty(reverse.Database); + var backward = await LogicalMigrationService.PreviewAsync(postgres, reverse, 3); + Check(backward.CanStart && backward.Tables.Single().ContentHash == forward.Tables.Single().ContentHash, "PostgreSQL -> SQLite real catalog and content"); + Check((await LogicalMigrationService.RunAsync(postgres, reverse, 3, backward, reverse.Database)).Outcome == LogicalMigrationOutcome.Verified, "PostgreSQL -> SQLite verified commit"); + using var pgOwner = DatabaseProviderFactory.Create(postgres); + foreach (var mutation in new[] + { + ("CREATE INDEX extra ON sample(number);", "DROP INDEX extra;"), + ("ALTER TABLE sample ALTER COLUMN number SET DEFAULT 1;", "ALTER TABLE sample ALTER COLUMN number DROP DEFAULT;"), + ("CREATE SEQUENCE extra_sequence;", "DROP SEQUENCE extra_sequence;"), + ("SELECT lo_create(1736281931);", "SELECT lo_unlink(1736281931);"), + ("CREATE COLLATION extra_collation FROM \"C\";", "DROP COLLATION extra_collation;"), + ("CREATE VIEW extra_view AS SELECT 1;", "DROP VIEW extra_view;"), + ("CREATE FUNCTION extra_proc() RETURNS integer LANGUAGE SQL AS 'SELECT 1';", "DROP FUNCTION extra_proc();"), + ("ALTER TABLE sample ENABLE ROW LEVEL SECURITY;", "ALTER TABLE sample DISABLE ROW LEVEL SECURITY;") + }) + { + await pgOwner.ExecuteAsync(postgres.Database, mutation.Item1); + var empty = Profile(Path.Combine(root, "empty-" + Guid.NewGuid().ToString("N") + ".db")); CreateEmpty(empty.Database); + var denied = await LogicalMigrationService.PreviewAsync(postgres, empty, 3); + Check(!denied.CanStart, "Production PostgreSQL catalog rejects feature: " + mutation.Item1); + await pgOwner.ExecuteAsync(postgres.Database, mutation.Item2); + Check((await LogicalMigrationService.PreviewAsync(postgres, empty, 3)).CanStart, "Catalog no longer blocks after fixture feature removal"); + } + } + finally + { + Npgsql.NpgsqlConnection.ClearAllPools(); + if (created) await admin.ExecuteAsync("postgres", "DROP DATABASE \"" + postgres.Database + "\";"); + Directory.Delete(root, true); + } + } + + private static string Required(string key) => Environment.GetEnvironmentVariable(key) ?? throw new InvalidOperationException("Missing isolated fixture variable " + key); + private static ConnectionProfile Profile(string path) => new() { Provider = DatabaseProviderKind.Sqlite, Name = "Isolated logical migration", Database = path }; + private static void CreateEmpty(string path) { using var connection = new SqliteConnection("Data Source=" + path); connection.Open(); } + private static async Task ObjectCountAsync(ConnectionProfile profile) + { + using var session = DatabaseProviderFactory.Create(profile); + var result = await session.ExecuteAsync(profile.Database, "SELECT count(*) FROM sqlite_schema;"); + return Convert.ToInt64(result.Rows.Single().Single()); + } + private static void Check(bool condition, string reason) { if (!condition) throw new InvalidOperationException(reason); } +} diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 4aa4b85..6ae711e 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -33,6 +33,7 @@ ("macOS Keychain 安全 round-trip", MacOsKeychainRoundTripAsync), ("SQLite 查詢與 DDL/DML", SqliteExecutesQueriesAsync), ("SQLite 背景查詢與原生取消", SqliteResponsiveCancellationAsync), + ("關聯式邏輯搬移與真實 SQLite 交易回歸", LogicalMigrationTests.RunAsync), ("執行計畫解析與安全規則", QueryPlanParsingAsync), ("資料字典結構與 HTML 匯出", DataDictionaryAsync), ("結構比較差異與報告", SchemaComparisonAsync), @@ -672,6 +673,7 @@ static Task DatabaseObjectFilteringAsync() if (string.Equals(Environment.GetEnvironmentVariable("MYSQLPUNK_LIVE_TESTS"), "1", StringComparison.Ordinal)) { + tests.Add(("SQLite 與 PostgreSQL 雙向邏輯搬移實機", LogicalMigrationTests.PostgreSqlLiveAsync)); tests.Add(("MySQL 實機連線、metadata 與 SQL", MySqlLiveRoundTripAsync)); tests.Add(("MariaDB 實機連線、metadata 與 SQL", MariaDbLiveRoundTripAsync)); tests.Add(("PostgreSQL 實機連線、metadata 與 SQL", PostgreSqlLiveRoundTripAsync)); diff --git a/mySQLPunk.Desktop/LogicalMigrationWindow.cs b/mySQLPunk.Desktop/LogicalMigrationWindow.cs new file mode 100644 index 0000000..b870d6e --- /dev/null +++ b/mySQLPunk.Desktop/LogicalMigrationWindow.cs @@ -0,0 +1,109 @@ +using System.Text; +using Avalonia.Controls; +using Avalonia.Layout; +using MySqlPunk.Core.Models; +using MySqlPunk.Core.Services; + +namespace MySqlPunk.Desktop; + +public sealed class LogicalMigrationWindow : Window +{ + private readonly ConnectionProfile _source; + private readonly ComboBox _target = new() { Name = "MigrationTarget", HorizontalAlignment = HorizontalAlignment.Stretch }; + private readonly ComboBox _version = new() { ItemsSource = new[] { "SQLite 3(3.37–3.53)", "PostgreSQL 14", "PostgreSQL 15", "PostgreSQL 16", "PostgreSQL 17" } }; + private readonly TextBox _database = new() { Name = "MigrationDatabase", PlaceholderText = "另建空目標資料庫名稱/SQLite 完整檔案路徑" }; + private readonly TextBox _password = new() { PasswordChar = '●', PlaceholderText = "目標密碼(只留記憶體,不讀秘密庫)" }; + private readonly TextBox _sshPassword = new() { PasswordChar = '●', PlaceholderText = "目標 SSH 密碼(如需要)" }; + private readonly TextBox _passphrase = new() { PasswordChar = '●', PlaceholderText = "目標 SSH key passphrase(如需要)" }; + private readonly TextBox _confirmation = new() { Name = "MigrationConfirmation", PlaceholderText = "確認後完整輸入目標名稱/路徑" }; + private readonly TextBox _report = new() { Name = "MigrationReport", IsReadOnly = true, AcceptsReturn = true, TextWrapping = Avalonia.Media.TextWrapping.Wrap, MinHeight = 240 }; + private readonly Button _inspect = new() { Name = "MigrationInspect", Content = "唯讀預檢與 SQL 預覽" }; + private readonly Button _apply = new() { Name = "MigrationApply", Content = "確認並搬移至空目標", IsEnabled = false }; + private readonly Button _cancel = new() { Name = "MigrationCancel", Content = "取消", IsEnabled = false }; + private CancellationTokenSource? _running; + private readonly CancellationToken _parentToken; + private LogicalMigrationPreview? _preview; + private ConnectionProfile? _approvedTarget; + private int _approvedMajor; + private bool _retryBlocked; + + public LogicalMigrationWindow(ConnectionProfile source, IReadOnlyList targets, CancellationToken parentToken) + { + _source = source.Clone(); _parentToken = parentToken; + Title = "可驗證邏輯搬移(階段一)"; Width = 850; Height = 790; WindowStartupLocation = WindowStartupLocation.CenterOwner; + var profiles = targets.Select(profile => profile.Clone()).ToArray(); + _target.ItemsSource = profiles.Select(profile => profile.Name + " · " + profile.ProviderDisplayName).ToArray(); + var fields = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(16) }; + fields.Children.Add(new TextBlock { Text = "來源:" + source.ProviderDisplayName + " / " + source.Database, TextWrapping = Avalonia.Media.TextWrapping.Wrap }); + fields.Children.Add(new TextBlock { Text = "不是原生備份 restore。此階段僅 SQLite STRICT ↔ PostgreSQL 14–17:INTEGER/bigint、BLOB/bytea、NULL/NOT NULL;其他型別、PK/FK、索引、default、identity/sequence、trigger/procedure、schema 相依一律預檢阻擋。目標須事先另建且為空。最多 100,000 列/64 MiB,單一交易+內容校驗,不提供逐表續寫。", TextWrapping = Avalonia.Media.TextWrapping.Wrap }); + foreach (var control in new Control[] { _target, _database, _version, _password, _sshPassword, _passphrase, _confirmation }) fields.Children.Add(control); + var buttons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + buttons.Children.Add(_inspect); buttons.Children.Add(_apply); buttons.Children.Add(_cancel); fields.Children.Add(buttons); + fields.Children.Add(_report); Content = new ScrollViewer { Content = fields }; + _target.SelectionChanged += (_, _) => + { + _preview = null; _apply.IsEnabled = false; + if (_target.SelectedIndex >= 0) _database.Text = profiles[_target.SelectedIndex].Database; + }; + _version.SelectionChanged += (_, _) => InvalidatePreview(); + foreach (var input in new[] { _database, _password, _sshPassword, _passphrase }) + input.PropertyChanged += (_, args) => { if (args.Property == TextBox.TextProperty) InvalidatePreview(); }; + _inspect.Click += async (_, _) => await WorkAsync(async token => + { + if (_target.SelectedIndex < 0 || _version.SelectedIndex < 0) throw new InvalidOperationException("請明確選取目標連線與版本。"); + var target = profiles[_target.SelectedIndex].Clone(); target.Database = _database.Text ?? ""; + target.Password = _password.Text ?? ""; target.SshPassword = _sshPassword.Text ?? ""; target.SshKeyPassphrase = _passphrase.Text ?? ""; + int major = _version.SelectedIndex == 0 ? 3 : 13 + _version.SelectedIndex; + var preview = await LogicalMigrationService.PreviewAsync(_source, target, major, token); + token.ThrowIfCancellationRequested(); + _preview = preview; _approvedTarget = target; _approvedMajor = major; + var text = new StringBuilder("來源 " + preview.SourceVersion + " → 目標 " + preview.TargetVersion + "\n目標:" + preview.TargetDatabase + "\n"); + foreach (var table in preview.Tables) text.AppendLine(table.Name + ":" + table.Rows + " 列;整數/二進位逐欄校驗(預覽不顯示資料值)"); + foreach (var sql in preview.Statements) text.AppendLine(sql); + foreach (var blocker in preview.Blockers) text.AppendLine("阻擋:" + blocker); + _report.Text = text.ToString(); + }); + _apply.Click += async (_, _) => await WorkAsync(async token => + { + if (_preview?.CanStart != true || _approvedTarget == null || _confirmation.Text != _approvedTarget.Database || + _database.Text != _approvedTarget.Database || _target.SelectedIndex < 0 || + profiles[_target.SelectedIndex].Id != _approvedTarget.Id || + (_version.SelectedIndex == 0 ? 3 : 13 + _version.SelectedIndex) != _approvedMajor || + (_password.Text ?? "") != _approvedTarget.Password || (_sshPassword.Text ?? "") != _approvedTarget.SshPassword || + (_passphrase.Text ?? "") != _approvedTarget.SshKeyPassphrase) + throw new InvalidOperationException("請完成預檢並輸入完整目標名稱;沒有寫入。"); + var result = await LogicalMigrationService.RunAsync(_source, _approvedTarget, _approvedMajor, _preview, _confirmation.Text!, token); + _preview = null; + _retryBlocked = result.RetryBlocked; + _report.Text = result.Outcome + ":" + result.Rows + " 列\n" + result.Report; + }); + _cancel.Click += (_, _) => _running?.Cancel(); + Closing += (_, args) => { if (_running != null) { args.Cancel = true; _running.Cancel(); } }; + Closed += (_, _) => { _password.Text = ""; _sshPassword.Text = ""; _passphrase.Text = ""; _approvedTarget = null; }; + } + + private async Task WorkAsync(Func operation) + { + if (_running != null || _retryBlocked || _parentToken.IsCancellationRequested) return; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_parentToken); + _running = cancellation; + SetBusy(true); + try { await operation(cancellation.Token); } + catch (OperationCanceledException) { _preview = null; _report.Text = "已取消預檢/尚未寫入的操作;需重新預檢。"; } + catch (Exception exception) { _preview = null; _report.Text = exception is InvalidOperationException ? exception.Message : "預檢/連線失敗:" + exception.GetType().Name + ";沒有自動重試。"; } + finally { _running = null; SetBusy(false); } + } + + private void SetBusy(bool busy) + { + foreach (var control in new Control[] { _target, _database, _version, _password, _sshPassword, _passphrase, _confirmation }) control.IsEnabled = !busy && !_retryBlocked; + _inspect.IsEnabled = !busy && !_retryBlocked; + _apply.IsEnabled = !busy && !_retryBlocked && _preview?.CanStart == true; + _cancel.IsEnabled = busy; + } + + private void InvalidatePreview() + { + _preview = null; _approvedTarget = null; _apply.IsEnabled = false; + } +} diff --git a/mySQLPunk.Desktop/MainWindow.axaml b/mySQLPunk.Desktop/MainWindow.axaml index 089e7af..6e52f80 100644 --- a/mySQLPunk.Desktop/MainWindow.axaml +++ b/mySQLPunk.Desktop/MainWindow.axaml @@ -65,7 +65,7 @@ - + +