From eccab36bb0a42f8008e71afb32302448b1e2ea94 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 13:48:10 +0800 Subject: [PATCH 01/32] docs(mssql-export): record approved wizard design and implementation plan --- docs/design/mssql-export/README.md | 44 ++ docs/design/mssql-export/design.md | 220 +++++++++ .../mssql-export/implementation-plan.md | 421 ++++++++++++++++++ docs/design/mssql-export/index.html | 161 +++++++ .../mssql-export/preview-progress-dark.png | Bin 0 -> 94920 bytes .../mssql-export/preview-progress-light.png | Bin 0 -> 97746 bytes docs/design/mssql-export/serve-preview.py | 59 +++ 7 files changed, 905 insertions(+) create mode 100644 docs/design/mssql-export/README.md create mode 100644 docs/design/mssql-export/design.md create mode 100644 docs/design/mssql-export/implementation-plan.md create mode 100644 docs/design/mssql-export/index.html create mode 100644 docs/design/mssql-export/preview-progress-dark.png create mode 100644 docs/design/mssql-export/preview-progress-light.png create mode 100644 docs/design/mssql-export/serve-preview.py diff --git a/docs/design/mssql-export/README.md b/docs/design/mssql-export/README.md new file mode 100644 index 0000000..3e4a18e --- /dev/null +++ b/docs/design/mssql-export/README.md @@ -0,0 +1,44 @@ +# MSSQL SQL 匯出精靈:互動畫面原型 + +這是 2026-10-12 設計討論用的獨立 HTML 原型,尚未接入 WinForms 或匯出引擎。所有資料、速度、ETA、檢查結果、成功與取消狀態均為模擬;不連線資料庫,也不產生 SQL 檔案。 + +使用者已於 2026-10-12 同意此畫面及修訂後的正式功能規格:[design.md](design.md)。[實作計畫](implementation-plan.md) 已整理,待審閱與選擇執行方式;正式匯出程式尚未實作。 + +## 開啟 + +可直接開啟 `index.html`,或以 Python 3.8+ 啟動本機預覽: + +```powershell +python docs/design/mssql-export/serve-preview.py --port 17643 +``` + +網址:`http://127.0.0.1:17643/docs/design/mssql-export/index.html` + +預覽伺服器只綁定 loopback,僅提供此頁與三個明確允許的 Punky 素材,不提供資料夾索引。按 Ctrl+C 停止。 + +## 可以操作 + +- 上方原型導覽直接切換選表、輸出設定、確認、進度與完成。 +- 選表預設全選;可搜尋表名/註解,跨搜尋保留勾選,零選取停用下一步。 +- 選擇結構與資料/僅結構/僅資料;編輯示意輸出路徑。 +- 右下情境選單可模擬未啟用快照,查看確認頁的提示與停用開始按鈕。 +- 從確認頁按「開始模擬匯出」,約 40 秒內走過資料匯出、檔案驗證與完成;取消則經過停止與清理的模擬狀態。 +- 深淺色切換;進度頁 Punky 自動循環播放,只有靜音控制。實際開始模擬時嘗試播放一次原音;仍受瀏覽器播放政策與裝置音量影響。 +- 開啟資料夾是示意動作,會提示沒有實際輸出。 + +## 素材與邊界 + +直接引用既有 `mySQLPunk/image/punky_report_loading_atlas.png`(384px、73 格、83ms/格)、靜態圖與 WAV,不複製大型素材。正式版仍需以真正的 MSSQL 工作狀態、吞吐量與檔案驗證結果驅動畫面;本原型的數字與進度算法不可當成實作。 + +原型導覽是設計檢視工具,正式精靈執行中不能藉此切換設定。正式 Windows 儲存檔案對話框、真實連線/SSH、快照條件、取消 SQL、串流匯出與還原驗證均未實作。 + +## 本機驗證紀錄(2026-10-12) + +- JavaScript 與 Python 語法檢查通過;本機 HTTP 回應 200。 +- 在 Brave 實際操作:預設 8/8 全選、清空停用下一步、註解「訂單」搜尋出 2 表、跨搜尋保留 7/8 選取,空路徑停用下一步。 +- 快照情境切為未啟用時,確認頁出現原因並停用開始;切回可用後可模擬匯出至完成。 +- 取消依序呈現「正在取消」與「本次匯出已取消」;深淺色、Punky loop 及畫面截圖已檢查。 +- 原型視窗限制高度、內容區捲動、固定底部操作列;目前瀏覽器尺寸下完整進度與三列工作狀態可見。其他 DPI/視窗尺寸的正式 WinForms 驗收尚未進行。 +- 未實際驗證裝置聽到的音效,也未執行應用程式/測試 EXE 或連線 DB。 + +已保存 [淺色進度圖](preview-progress-light.png) 與 [深色進度圖](preview-progress-dark.png)。 diff --git a/docs/design/mssql-export/design.md b/docs/design/mssql-export/design.md new file mode 100644 index 0000000..1c611ca --- /dev/null +++ b/docs/design/mssql-export/design.md @@ -0,0 +1,220 @@ +# MSSQL 大量資料 SQL 匯出精靈設計 + +日期:2026-10-12 + +狀態:互動畫面及本份修訂規格已獲使用者同意;2026-10-12 使用者回覆「照修訂版進入實作計畫(建議)」。[實作計畫](implementation-plan.md) 已整理,待審閱與選擇執行方式。尚未實作產品匯出引擎。 + +2026-10-12 修訂:使用者要求調整單筆大欄位限制,關心 geometry/長文字,並接受極端型別後續再擴充。取消原草案「單列 SQL 超過 16 MiB 就拒絕」規則;一般長文字/binary 改為可分段輸出,原生 spatial 型別仍列後續擴充。型別可容納的大小是設計目標,不代表所有理論上限已完成實機驗收。 + +## 1. 已確認的目標 + +使用者要升級 MSSQL 的「建立備份」及「匯出結構與資料」,解決大量資料時畫面卡住、記憶體不足與無法掌握進度的問題。第一版限定本機 `.sql` 邏輯匯出,來源通常仍持續新增、修改資料。 + +2026-10-12 使用者看過互動原型後回覆:「畫面很棒,可以同意」。此確認記錄為畫面與互動方向的驗收,不代表真正串流匯出、DB 一致性或還原已驗證。 + +- 原型:[index.html](index.html);操作:[README.md](README.md)。 +- 淺色:[preview-progress-light.png](preview-progress-light.png)。 +- 深色:[preview-progress-dark.png](preview-progress-dark.png)。 +- 流程:選表 → 輸出設定 → 確認/前置檢查 → 執行/結果。 +- 保留雙層進度、逐表狀態、實際筆數與檔案大小、耗時與 ETA、右側 Punky、自動動畫與靜音、固定底部取消按鈕。 + +## 2. 範圍與實作方向 + +### 本版納入 + +1. 既有 WinForms MSSQL 互動式本機 SQL 匯出入口共用專用精靈及 worker;依入口預選結構與資料、僅結構或僅資料。資料庫入口預設全選,單表入口只預選指定資料表。 +2. 原生 SQL Server 型別與結構的正確腳本、逐表串流資料、大欄位分塊輸出、可取消查詢、串流檔案完整性驗證。 +3. 持續寫入來源採同一個 SNAPSHOT 交易;不自動變更 DB 設定。 +4. 「建立備份」保留目前選用的備份鏡像與保留份數功能;一般手動 SQL 匯出不因此新增鏡像。 +5. 沿用現有 Punky 素材、ThemeManager、UiKit、獨立連線及 SSH transport 的生命週期。 + +### 本版不納入 + +SQL Server 原生 `.bak`、還原工具重寫、排程/自動備份改造、其他 provider 重構、暫停或斷點續傳、平行多表讀取、壓縮/加密輸出,以及伺服器登入、權限、SQL Agent job 等完整災難復原能力。 + +此功能是所選物件的 SQL 邏輯匯出,不以「建立備份」舊選單名稱暗示等同完整 `.bak`。精靈確認與結果必須列出實際包含/未包含範圍。 + +### 選定的架構 + +新增 MSSQL 專用匯出服務與表單。另一方案是把所有 provider 的通用 dump API 全面改成串流,但會擴大 MySQL、PostgreSQL、SQLite 等既有行為的回歸範圍,因此留待有明確需求再做。 + +不以現有 `BuildDatabaseDump()`/`BuildTableDump()` 的字串結果餵給背景執行緒;那仍會累積整份 SQL。也不把報表 wizard 繼承成通用 wizard framework。 + +## 3. 已查到的根因與相容性注意 + +- `Form1.cs` 的非 MySQL 本機 dump 和 MSSQL「建立備份」同步執行,會占用 UI 執行緒。 +- `DatabaseDumpService.cs` 累積每表與整庫 StringBuilder,最後才寫檔;分頁查詢沒有解除輸出記憶體限制。 +- `BackupIntegrityService.cs` 對 SQL 使用 `File.ReadAllText`,鏡像後也可能再次整檔讀入。 +- `my_mssql.cs` 分頁使用 OFFSET;無 PK 時使用任意排序。新 worker 不沿用分頁路徑,每表只開一個連續 reader。 +- 現有建表語法使用跨庫 copy 型別映射,會改變 varchar、datetimeoffset、uniqueidentifier 等語意,也缺少 identity/computed 等必要 metadata。 +- `SecuredDatabase` 包裝 SSH lease。直接將 `IDatabase` 強制轉型成 `my_mssql` 在 SSH 路徑不可行。 +- 目前發布流程有 delete-then-move 缺口;新路徑不得先刪除舊目標檔案。 + +## 4. 畫面與操作契約 + +### 選擇資料表 + +- 顯示 schema、表名、comment、估計筆數與資料大小;以 schema/name/object_id 識別,不以顯示文字識別。 +- 可搜尋名稱及 comment。搜尋不清除其他資料表的選取;「全選全部」明確作用於全部資料表。 +- 零選取停用下一步。metadata 載入在背景執行,取消/失敗有明確狀態。 +- 資料大小是來源儲存量估計,不等同最後 SQL 大小;缺少估計資訊時顯示未知,不以 0 替代。 + +### 輸出設定 + +- 模式為結構與資料、僅結構、僅資料;使用 Windows 儲存檔案對話框選本機路徑,輸出 UTF-8 `.sql`。 +- 若目標已存在,確認覆寫後仍保留原檔直到新檔驗證與安全替換完成。 +- 庫級結構匯出須保留既有檢視定義能力,提供「包含檢視定義」選項,庫級入口預設勾選;僅資料不包含檢視定義。這是原型未展示、正式版需補上的選項。 +- 備份入口若有鏡像設定,確認頁顯示啟用狀態及目的地;不將登入密碼/連線字串放入畫面或日誌。 + +### 確認與前置檢查 + +列出來源、模式、表與檢視範圍、輸出路徑、估計工作量、一致性模式、鏡像,以及具體不支援項目。檢查失敗須指出物件及原因,保留選取供修正。 + +未啟用 SNAPSHOT 時,含資料模式停在此頁;不能自動降級成一般讀取或 NOLOCK。僅結構不需要跨表資料快照,但仍檢查 schema 變動與定義可讀性。 + +開始按鈕捕獲不可變的本次 request;執行期間不能改選取、路徑或模式。原型頂端的任意跳頁工具不進正式產品。 + +### 執行與結果 + +- 階段:準備 → 結構 → 逐表資料 → 檔案驗證 → 本機發布 → 選用鏡像 → 結果。 +- 同時顯示整體估計進度、目前表進度/已讀列數、表名與 comment、已完成表數、SQL 已寫 bytes、elapsed、速率與 ETA。 +- 詳細紀錄只留有上限的操作紀錄,不逐列寫 UI log,不記資料內容或秘密。 +- 取消按鈕固定在底部。按取消後顯示「正在取消…」,等 worker 清理結束才顯示已取消。 +- 結果區分成功、取消、失敗,以及本機成功但鏡像未完成;只有已發布產物提供開啟資料夾。 +- SQL 可能非常大,完成後不自動把整個檔案載入應用程式編輯器。 + +### Punky + +直接重用 `PunkyReportLoadingControl`。開始作業時 `BeginExport()` 自動 loop,原音只播放一次;靜音立即停聲、取消靜音不重播。只有靜音按鈕,無播放/暫停。完成、失敗、取消後 `EndExport()`;關閉/Dispose 釋放聲音、Timer 與素材資源。保持深淺色可讀性與 DPI/較小工作區的取消可達性。 + +## 5. Worker、連線與一致性 + +1. UI 只捕獲本次選擇及必要的連線設定,worker 經既有安全連線流程取得獨立連線及 SSH lease;不得借用主視窗 provider。 +2. 在 `ConnectionOpenService` 邊界提供最小的內部 MSSQL 工作連線存取方式,保持 wrapper 及 raw SqlConnection 同一擁有者。不改動所有 provider 的 `IDatabase` public contract,不以另開連線字串繞過 transport 生命週期。 +3. 前置檢查讀取目標 DB 的 `snapshot_isolation_state`,僅 ON 接受含資料快照;過渡狀態不當作可用。只開 `READ_COMMITTED_SNAPSHOT` 不等於這次跨表交易已使用 SNAPSHOT。 +4. 使用者選擇期間不持有快照。按開始後重做必要檢查,在指定資料庫開始一筆明確的 SNAPSHOT transaction,必要來源查詢均使用同一交易。 +5. 所有選取表依序用同一交易讀取;不能每表重新開交易,也不能中途重新連線續跑而仍宣稱同一時點。 +6. SNAPSHOT 不保證 metadata 可任意變更。預檢後凍結本次物件定義;物件變更、不可讀、schema 變動導致查詢失敗等情況中止本次工作,不跳過失敗表後顯示成功。 +7. 讀完最後來源資料及必要來源查核後立即結束交易並釋放 DB/SSH 資源;本機驗證、發布、mirror 不延長快照持有時間。 +8. 清楚提示長時間快照需要保留資料版本;不承諾零負載或完全無阻塞。取得不到版本儲存監控權限不應成為一般匯出的額外權限門檻。 + +參考:[Microsoft Snapshot isolation](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sql/snapshot-isolation-in-sql-server)。 + +## 6. 有界記憶體與 SQL 正確性 + +### 讀寫方式 + +- 每表以明確欄位清單開啟 `SqlDataReader`/`SequentialAccess`,避免 OFFSET、整表 DataTable 或整表字串。 +- 不為取得精確進度先對每表完整 COUNT;使用 metadata 估計並保留未知狀態。 +- 文字與 binary 大欄位使用固定大小 buffer 分段轉義/十六進位寫出;不能先完整 GetString/GetValue/byte[] 後才分塊。 +- writer 可緩衝小段輸出,不累積整表、整份檔案或一個巨大 INSERT。一般小列走明確欄位 INSERT;整列的小資料緩衝預算最多 256 KiB,超過即轉分段路徑,不視為資料不能匯出。適當加 GO 分批,不把整個資料匯入綁成單一巨大 transaction。 +- 資料大小與 SQL 指令大小分開管理。大欄位以至多 64 KiB 原始 binary 或 16K 個 UTF-16 碼單元為起始 chunk 目標;轉義、hex 膨脹與字元邊界均算入實際大小。一般資料 statement 以 256 KiB 為生成預算,GO batch 目標不超過 4 MiB,必要時進一步縮小 chunk。這些是可調整的內部緩衝/批次參數,不是單筆資料的拒絕上限;不得在 INSERT/UPDATE 中間插 GO,亦不截斷欄位值。 +- bytes、筆數、時間計量使用可容納大檔案的型別;顯示及 I/O 不截成 32-bit int。 +- 查詢等待可取消,避免沿用一般查詢的短 timeout。worker 的長查詢 command 必須直接配置 timeout,不能依賴目前不涵蓋 local command 的 `setTimeout()`。 + +參考:[Microsoft SqlClient streaming](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sqlclient-streaming-support)。CancellationToken 搭配所屬 SqlCommand.Cancel,不能只期待 token 自動中斷正在等待的 reader。 + +SQL Server 的 batch 長度受 network packet size 影響,總檔案可達數 GiB 不代表單一 statement 可以任意大。本版限制生成批次的大小,讓受支援的大欄位跨多個批次重建;不再以 16 MiB 定義通用單列上限。驗收使用支援 GO 的 Microsoft `sqlcmd`,記錄工具/伺服器版本與輸入設定。既有 mySQLPunk 匯入端仍需獨立驗證,不能假設它支援無界大檔。[Microsoft 容量限制](https://learn.microsoft.com/en-us/sql/sql-server/maximum-capacity-specifications-for-sql-server?view=sql-server-ver17) + +### 大欄位分段還原 + +1. 大列在同一個匯入 session 的私有 `#temp` staging 區組裝;不依賴目標表有 PK,不直接先插入空附件再反覆 UPDATE 目標列。先完整組裝及驗證,再一次 INSERT 到目標,避免約束/trigger 看到半列。 +2. `varchar(max)`、`nvarchar(max)`、`varbinary(max)` 使用對應 staging 型別與 `.WRITE(chunk, NULL, 0)` 追加。來源非 NULL 值先初始化空值,來源 NULL 另行標記,不能把 NULL 與空字串/空 binary 混為一談。varchar staging 明確使用來源 collation,不能繼承 tempdb 的預設定序而改變資料。 +3. 文字分段不切斷 surrogate pair,保留換行、尾端空白、單引號與字元語意;以 DATALENGTH 而非 LEN 核對 bytes。`text/ntext/image` 採相應的 max staging 與明確目標轉型,必須有還原測試,不能更改原表 DDL 型別來迴避問題。原生特殊型別使用獨立 codec,不假裝成一般文字。 +4. worker 按 reader 欄位次序讀取;小列緩衝超過門檻時,已讀的有界資料也轉入 staging 指令,之後繼續串流,不重新查來源列、不依賴 PK 或 OFFSET。 +5. staging 記錄工作/列識別、欄位、chunk 次序、已收 bytes、預期完成數與錯誤/完成狀態。只有所有欄位完整,最後短 transaction 才能執行目標 INSERT 並標記 consumed;缺段、重複、順序錯誤、轉型失敗、重複執行最後批次或 staging 不存在都明確失敗。 +6. 每個 batch 自己處理錯誤;TRY/CATCH 與區域變數不能跨 GO。不能只依靠 XACT_ABORT 或客戶端停止選項防止半列插入;即使後續批次被執行,最後完整性 guard 也須阻止錯誤資料進目標。SQL NULL 不能作為轉型失敗的替代結果。 +7. 匯入工具必須全檔維持同一 session,支援 GO,錯誤時停止,且不展開來源文字中的 sqlcmd `$(...)`。驗收使用單次 `sqlcmd -i`、`-b`、`-x` 及明確 UTF-8 輸入設定;不把必須同 session 的脚本分成多次連線執行。正式交付文件列出經驗證的實際參數;GUI 匯入只有通過同樣測試後才宣稱支援。 +8. 每列 INSERT 完就清掉該列 staging,不累積整張表。匯出端維持有界記憶體;還原端仍需足夠 tempdb/交易記錄與目標儲存空間容納當前大列,不能承諾無限資源。空間不足時,該列不得以殘缺值發布。 + +以上是設計方案,尚未執行 SQL 驗證。文件依據:[Microsoft UPDATE .WRITE](https://learn.microsoft.com/en-us/sql/t-sql/queries/update-transact-sql?view=sql-server-ver17)、[GO 與 batch 範圍](https://learn.microsoft.com/en-us/sql/t-sql/language-elements/sql-server-utilities-statements-go?view=sql-server-ver17)。 + +### 空間資料與後續擴充 + +- MSSQL 長文字主要是 `varchar(max)`/`nvarchar(max)`,亦可能有既有 `text/ntext`;`longtext` 是 MySQL 的型別名稱。本版只處理 MSSQL。 +- 若 WKT 或其他內容實際存在一般文字/binary 欄位,依原欄位型別串流保存,不解析、縮短或簡化其中內容。 +- 原生 `geometry`/`geography` 本版在 metadata 預檢指出表與欄位,列為尚未支援;保留未來專用 codec 擴充點。後續需驗證原生內容、SRID、空間維度等還原資訊,不直接改成 nvarchar 來繞過。 +- 使用者接受遇到極端型別再擴充,因此本版不要求同時支援所有空間/自訂型別,也不把未測試的多 GiB 邊界宣稱為已通過。真正超過型別容量、物件無法保真或環境資源不足時才明確停止,保留原正式檔案。 + +### 支援與預檢 + +v1 以一般 disk-based user tables 為主,從 `sys.*` 收集原生定義;不得沿用跨庫 type mapper。至少處理: + +- schema/name 正確引用,含空格、Unicode、點號、右方括號等名稱;不把整個 `sales.orders` 引用成單一識別字,也不將目標鎖死在來源 DB 名稱。 +- 原欄位型別、長度、precision/scale、nullability、collation、default 與表/欄位 comment。 +- Unicode/單引號/換行、NULL 與空值、binary;decimal(38,s) 不經不足精度的 CLR decimal;日期時間保留對應型別的精度與 datetimeoffset offset。 +- identity seed/increment 與既有欄位值,適當的 IDENTITY_INSERT;還原後新 identity 不與已匯入資料衝突。不承諾重建來源 identity 配號留下的所有間隙。 +- computed 定義/persisted;INSERT 排除 computed 與 rowversion,rowversion 由目標重生且在摘要說明。 +- 一般 rowstore PK/UQ/index,保留名稱、key 次序、ASC/DESC、INCLUDE、filter 與適用屬性。FK/CHECK 在資料之後建立,保留其有效/信任狀態。 +- 檢視定義用獨立 batch,保留必要 SET 選項、處理所選範圍內的依賴順序。無法讀取/加密/依賴未包含物件時預檢提出,不產生看似完整的結果。 + +第一版不支援的特殊型別/表/索引特性須在預檢明列並阻擋受影響選取,不可默默轉成其他型別或省略。包含 temporal/ledger、memory-optimized、FILESTREAM、CLR/alias UDT、Always Encrypted、sql_variant、spatial、typed XML、partition/特殊索引等。新增支援必須有對應還原證據。 + +依賴自訂 function、sequence、trigger 或未匯出物件的定義需要具體列出;不猜測可用性。結構模式若選取不完整 FK/view 依賴,要求調整範圍,不用 NOCHECK 隱藏缺漏。僅資料模式明示需要相容且已建立的目標結構,不保證任意既有資料庫都能直接匯入。 + +## 7. 進度與 ETA 契約 + +- 以 immutable progress snapshot 回報工作 ID、階段、表、完成/估計列數、完成表數、已寫 bytes、elapsed、速率、可空的 ETA 及估計可信狀態。 +- UI Progress 在 UI context 接收;一般更新約 100–250ms 節流,階段/表切換、錯誤及完成立即回報。不每列塞入 message queue。 +- 開始與樣本不足時顯示「估算中」;網路/DB 等待或速率劇烈變動時退回估算中或顯示正在等待,不維持假倒數。 +- 用最近一段時間的平滑吞吐量估算。不同表列寬與 LOB 差異須納入估计工作量,不以完成表數等權平均。沒有足夠估計值時保持 indeterminate,仍顯示實際已完成值。 +- metadata row count 不必然等於快照實際列數;實際大於估計時更新說明,不越過 100% 或提早完成。 +- 整體百分比明標估計,包含後續檔案驗證/鏡像工作;階段切換不能重置整體為 0。驗證/鏡像可用實際 bytes 作分階段精確進度。 +- 完成前最多 99%;只有要求的步驟皆完成才為 100% 成功。鏡像失敗不得以整體成功覆蓋警告,但本機成功須保留。 +- 不保證 ETA 的固定誤差百分比;驗收其資料來源、變動/未知行為與不欺騙的完成判斷。 + +## 8. 取消、驗證與發布 + +### 狀態 + +Idle → Preflight → Reading/Writing → Verifying → Publishing → Mirroring(可選)→ Completed。 + +可中斷階段收到取消後轉 CancelRequested → 清理 → Canceled。不可重複的短發布區段使用明確互斥;若發布已完成,結果不得再假稱完全未輸出。 + +### 暫存與驗證 + +1. 在目標同目錄建立工作專屬的不可碰撞 `.partial` 檔,以 CreateNew 建立;不刪除其他工作或上次未知的暫存檔。 +2. writer 記錄 bytes、工作識別、已完成物件及列數,完整成功時才寫完成標记;關閉/flush 成功才進入驗證。 +3. 以固定 buffer 重新讀取,核對寫出時的 hash、長度與必要完整性標記。不得 ReadAllText、ReadToEnd,亦不可用可能整行巨大 INSERT 的無界 ReadLine。 +4. 驗證表示檔案與本次完整寫出結果相符,不等於 SQL 已在目標伺服器成功執行。真正語意正確性另由還原測試取得。 +5. 最後取消檢查之後,無目標時用不可覆寫 move;已有獲同意覆寫的目標時用同磁碟安全 replace。替換不支援或失敗時不退回先刪原檔;回報失敗及可恢復路徑。 +6. 確認覆寫後若目標又被其他工作變更,偵測衝突並停止發布,不能覆蓋未確認的新版本。 +7. 取消/失敗僅清理本工作暫存檔;清理失敗要列出殘留路徑,不把不完整檔案當成正式產物。日誌不含連線秘密或 SQL 資料值。 + +### UI 關閉 + +執行中關閉視窗視同取消請求,等 worker 清理完成再關閉。UI 不直接 Dispose 正在讀取的共享資源;operation ID 阻止舊工作回報污染新畫面。 + +## 9. 備份鏡像相容性 + +- 只對原本會 mirror 的備份入口保留鏡像;目的地及保留份數沿用設定,不擅自另建雲端/遠端服務。 +- 本機產物發布後,鏡像用工作專屬暫存檔分塊複製並回報 bytes;副本驗證完成才發布並執行既有保留規則。 +- 不能先清理舊備份再發現新副本無效。不得擴大既有清理規則至不相關檔案。 +- mirror 失敗、取消或目的地不可用:保留已成功本機檔案與既有遠端備份,結果明示「本機已完成,鏡像未完成」,附可採取的下一步。 +- 從 mirror 開始就已存在本機正式檔案;取消文案與結果須反映這個發布邊界。 + +## 10. 驗收與證據 + +| 項目 | 必須取得的證據 | +| --- | --- | +| 編譯 | 正式方案 Debug/Release 通過,既有 provider 路徑未被不必要變更 | +| 畫面 | WinForms 真正選表、查詢中不凍結、雙層進度、DPI/小工作區、深淺色、取消/關閉生命週期 | +| 大資料 | 超過 2 GiB 的實際產物;來源擴大至少 10 倍時記憶體不按匯出總量線性增加,记录 peak、rows、bytes、elapsed | +| 大欄位 | 跨過舊 16 MiB 門檻的文字/binary 必須走分段且可還原,核心案例至少 64 MiB binary 及超過 16 MiB 的 Unicode 文字;比對 bytes/雜湊、NULL/空值、尾空白、跨 chunk 引號與 surrogate、無 PK、多 LOB 同列。缺段/重複/故意轉型或空間失敗後不得插入半列。超過 1 GiB 與接近型別理論上限的壓力測試另列環境條件與結果,未測不得宣稱通過 | +| 空間欄位 | 原生 geometry/geography 在預檢具體指出並阻擋受影響選取;一般文字欄位中的大型 WKT 保留原文字,不自動轉型、截斷或簡化 | +| 型別與結構 | 在獨立測試 DB 匯入實際產物,驗證精度、offset、Unicode、binary、identity 後續新增、computed、rowversion 語意、constraints/indexes/view | +| 持續寫入 | 用另一連線交易性修改父子表,匯出仍對應同一快照,不混用不同時間點;版本條件不足時預檢停止 | +| 取消與競態 | 讀取/寫入/驗證/鏡像時取消、關閉窗、查詢等待、DB 中斷、磁碟滿、覆寫競態;原檔與結果狀態符合發布契約 | +| SSH | 透過工作自己的 transport 成功、取消、失敗後釋放;不影響主視窗連線 | +| 鏡像 | 成功驗證後保留清理;失敗/取消不清掉舊可用備份,本機產物仍可找到 | + +測試來源限明確的隔離/可拋棄 fixture,不在使用者正式 DB 建測試資料或擅改 SNAPSHOT 設定。沒有可用測試 DB 時,實機還原、持續寫入一致性、SSH 與大資料效能必須列 pending,不能由編譯或 fake-provider 測試代替。 + +保留既有 `ReportExportTests.exe` 防毒隔離邊界:不還原/執行、不變名重跑、不調整防毒。新增功能測試選用可用且未被隔離的正常測試管道;若環境阻擋就明列未驗證,不繞過。 + +## 11. 交付與流程狀態 + +- 本次已交付獨立互動原型;真實匯出程式與 DB 驗收未完成。 +- 本文件已獲同意,具體實作步驟與測試安排見 [implementation-plan.md](implementation-plan.md)。規格核准不擴張為 merge、push 或 release 授權。 +- 舊版入口相容、metadata/SQL writer、worker/取消/發布、精靈與進度、實機 fixture 是同一功能的必要工作,不能只接好畫面就宣布完成。 +- 開發過程保留既有檔案 BOM/換行、未相關工作樹內容;重要結果追加 `history.md`。 diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md new file mode 100644 index 0000000..3e64349 --- /dev/null +++ b/docs/design/mssql-export/implementation-plan.md @@ -0,0 +1,421 @@ +# MSSQL 大量資料 SQL 匯出精靈 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 將 MSSQL 互動式本機 `.sql` 匯出接到專用精靈,以有界記憶體依序匯出一致快照,顯示進度/ETA/Punky,安全取消與發布,並以實際還原驗證資料。 + +**Architecture:** 新增 MSSQL 專用 metadata、SQL writer、worker 與 WinForms 精靈;不重寫其他 provider 的 dump API。worker 擁有獨立 SQL connection/SSH owner,以同一 SNAPSHOT 交易讀取選取表;小列 INSERT、大列在還原 session 的私有暫存表分段組回,再插入目標。檔案串流驗證、原子發布、鏡像在來源交易釋放後執行。 + +**Tech Stack:** 現有 .NET Framework 4.7.2/WinForms/System.Data.SqlClient/MSBuild;既有 ThemeManager、UiKit、PunkyReportLoadingControl、smoke harness;Microsoft sqlcmd 作還原驗收。第一版不增加 NuGet 依賴或換資料庫 driver。 + +**Spec:** [design.md](design.md),2026-10-12 修訂版;使用者已回覆「照修訂版進入實作計畫(建議)」。 + +**Status:** 使用者於 2026-10-12 核准計畫並選擇「分工實作並逐項覆核」;實作已開始,checkbox 依驗證進度更新。畫面原型及規格核准不表示 SQL 還原、效能或正式程式已完成。 + +## Global Constraints + +- 範圍是 MSSQL 互動式本機 SQL 邏輯匯出;原生 `.bak`、排程/自動備份改造、其他 provider 重構、暫停/續傳、壓縮/加密均不納入。 +- 持續寫入來源採同一個 SNAPSHOT 交易;不自動變更 DB 設定。`snapshot_isolation_state` 必須是 ON,僅結構模式例外;不降級為 NOLOCK/一般讀取。 +- 每表只開一個 SequentialAccess reader;不得整表 DataTable、OFFSET 分頁、先 COUNT 全表、完整 GetValue/GetString 大欄位或完整 SQL StringBuilder。 +- 整列的小資料緩衝預算最多 256 KiB;binary chunk 至多 64 KiB,文字起始 chunk 至多 16K 個 UTF-16 碼單元;一般資料 statement 生成預算 256 KiB,GO batch 目標不超過 4 MiB。計入實際編碼/hex/轉義 bytes;這些不是單列拒絕上限。 +- 跨 GO 組裝需要同一匯入 session;先完整組裝及驗證,再一次 INSERT 到目標。`sqlcmd -i`、`-b`、`-x`、明確 UTF-8 輸入設定為驗收契約。 +- 原生 geometry/geography、UDT 等 spec 所列特殊能力預檢阻擋受影響選取;一般文字中的 WKT 不解析、縮短或簡化。 +- 進度一般更新約 100–250ms;完成前最多 99%;未知 ETA 顯示「估算中」,不是 0 秒。鏡像未完成不可顯示整體成功。 +- 取消等 worker 清理;失敗不先刪原正式檔。鏡像驗證/發布後才清理保留份數,沿用原有分類規則。 +- Punky 使用現有素材及 BeginExport/EndExport/Muted;自動 loop、原音一次、只有靜音按鈕,不增加播放/暫停。 +- 保留既有 BOM/換行及無關工作,尤其 `mySQLPunk.Desktop/Properties/`;不以 `git add -A` 收檔。重要決策與證據追加既有 `history.md`。 +- 不還原/執行/重命名或重 host `ReportExportTests.exe`,不搬其測試到其他 runner,也不更改防毒。新 MSSQL 測試若受阻,停止並列 pending。 +- 本計畫的建庫、還原與大量資料測試只能在明確隔離 fixture 執行;不使用使用者正式 DB/已儲存帳密。沒有 fixture 就保留 DB 驗收 pending,不能用編譯代替。 + +## Review Focus + +以下五類容易落在主流程外,已各自納入後面測試步驟: + +1. 資料包含換行後的 `GO`、`:r`、`$(name)`、NUL/非 BMP 字元;匯入不能把資料當作工具命令或變數,值須完整還原(Task 4、5、10)。 +2. 兩個 schema 有同名表,或預檢後 drop/recreate;不能依顯示字串選錯表,也不能把新物件當原快照(Task 2、7、8)。 +3. 目標檔在覆寫同意後改變,或取消恰好遇到發布/鏡像;不得刪掉另一工作的檔案或把已發布結果說成未輸出(Task 6、7、10)。 +4. SQL Server 的 metadata 欄位因版本不同而不存在、權限不足、view 定義不可見;須區分不支援與讀取失敗,不能當成「沒有資料/沒有約束」(Task 2、3、10)。 +5. 無 PK、多個大欄位、來源空值、目標 tempdb 定序不同,或還原 client 在錯誤後仍繼續;不得插入半列、改變 bytes 或重複最後 INSERT(Task 4、5、10)。 + +## 檔案與接點 + +以下為計畫中的新檔,尚不存在。新引擎放 `mySQLPunk/lib/MssqlExport/`,namespace `mySQLPunk.lib.MssqlExport`,型別預設 internal;避免擴大公用 IDatabase contract。 + +| 檔案 | 責任/擁有者 | +| --- | --- | +| `MssqlExportModels.cs` | 不可變 request、metadata、progress、結果、限制值及來源測試介面 | +| `MssqlOwnedSession.cs` | owner 與原生 SqlConnection 成對持有,單一 Dispose | +| `MssqlExportMetadataReader.cs` | sys metadata、能力偵測、預檢及 schema fingerprint | +| `MssqlExportSource.cs` | 快照交易、command/reader/取消登記、來源生命週期 | +| `MssqlSchemaScriptWriter.cs` | 原生 DDL、依賴排序、index/constraint/view/comment | +| `MssqlDataScriptWriter.cs` | 逐列/逐欄 reader、有限小列緩衝與分段切換 | +| `MssqlValueCodec.cs` | 型別保真、identifier/scalar/資料 literal 編碼 | +| `MssqlLobScriptWriter.cs` | 大列 staging、chunk、完整性 guard 與最後 INSERT | +| `MssqlExportArtifact.cs` | 工作暫存、incremental hash、重讀驗證、發布/清理 | +| `MssqlExportMirror.cs` | 分段鏡像、驗證後發布及既有 retention | +| `MssqlExportProgressEstimator.cs` | 計量、節流、平滑 ETA/未知狀態 | +| `MssqlSqlExportService.cs` | 階段 orchestration、錯誤/取消與完整結果 | +| `mySQLPunk/MssqlExportWizardForm.cs` | 專用 WinForms 精靈,沿用樣式與 Punky | +| `mySQLPunk/Form1.MssqlExport.cs` | 既有四個互動入口共用的設定捕獲及開窗接點 | +| `tests/SmokeTests.MssqlExport.cs` | 新 MSSQL 純單元、fake-reader、檔案與 UI 狀態測試 | +| `tests/SmokeTests.MssqlExport.Integration.cs` | 明確啟用的隔離 DB/restore/stress 測試,不預設跑 | +| `tests/Run-MssqlExportIntegration.ps1` | 指定測試 server、profile、全新輸出目錄,保存證據 | +| `docs/MSSQL_SQL_EXPORT.md` | 實際支援範圍、操作、還原命令、資源與錯誤處理 | + +確定需要的既有修改:`lib/ConnectionOpenService.cs`、`Form1.cs`、`mySQLPunk.csproj`、`Properties/AssemblyInfo.cs`、`tests/SmokeTests.cs`、`tests/Run-SmokeTests.ps1`。`AssemblyInfo` 只新增測試 friend assembly,不改版本。沿用 `BackupRemoteMirrorService.PruneRemoteBackups`,不呼叫會提早 prune 的 `MirrorBackup`,不必改既有其他 provider 的方法。 + +不需要修改 `my_mssql.cs`、`ConnectionSecurityService.cs`、`IDatabase`、`DatabaseDumpService`、`BackupIntegrityService`、既有報表 writer/動畫素材或 `.bak` 表單。若後續證據要求修改,先說明必要性,不順手重構。 + +### 現有入口(2026-10-12 唯讀定位) + +- `Form1.cs:6634` `DumpSelectedTableSql(bool dataOnly)`:單表右鍵,另有自己的同步 dump。 +- `Form1.cs:6902` `DumpCurrentSelectionSqlWithDialog(bool dataOnlyForTable)`:工具列/群組;view selection 不可誤轉全庫。 +- `Form1.cs:7258` `DumpSelectedDatabaseSqlWithDialog()`:資料庫結構與資料。 +- `Form1.cs:8040` `BackupSelectedDatabaseWithDialog()`:建立備份;此入口捕獲 `BackupMirrorSettings.RemoteDirectory/RetainCount`。 +- `CreateDatabaseBackup` 還被還原前/刪除前備份等流程呼叫,本版不全面替換它。只在上述互動 handler 的 MSSQL 分支轉精靈,其他 caller 保持原流程並在操作文件註明範圍。 +- `ConnectionOpenService.Open(Dictionary, false)` 已持有 raw provider 與可能的 SSH wrapper;在這個邊界傳遞原生連線,不 unwrap private 欄位或另開 connection string。 + +## 共用契約與測試方式 + +### 跨 task 的型別 + +全部在 Models 檔定義,collection 在建構時複製成唯讀;不得讓 UI 在開始後改動 request。 + +- `MssqlObjectKey`:`int ObjectId`、`string Schema`、`string Name`;metadata identity 以 object_id 加原始 schema/name 為準,SQL 引用各段名稱。 +- `MssqlExportMode`:`SchemaAndData, SchemaOnly, DataOnly`。 +- `MssqlMirrorOptions`:`string Directory`、`int RetainCount`;null 表示未啟用。 +- `ExportDestinationStamp`:目標是否存在、正規路徑、檔案 identity/長度/最後寫入時間及 streaming SHA-256;在確認覆寫時捕獲,在發布前重新比對。不將 UI 同意當永久覆寫權。 +- `MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList tables, MssqlExportMode mode, bool includeViews, string targetPath, ExportDestinationStamp destination, MssqlMirrorOptions mirror)`;驗證非空選取、路徑、data-only 無 views。 +- `MssqlTableDefinition`/`MssqlColumnDefinition`/`MssqlIndexDefinition`/`MssqlConstraintDefinition`/`MssqlViewDefinition`:Task 2 依 spec 定義 native metadata;禁止沿用跨庫映射字串。Column 必有 ordinal、native type、length、precision/scale、collation、nullable、identity、computed、rowversion、comment;Table 含 key、comment、nullable estimated rows/bytes。 +- `MssqlExportCatalog`:tables、snapshot state、readability/capability issues;`MssqlExportIssue`:code、object key、optional column、可呈現原因。 +- `MssqlExportPreflight`:本次選取的schema摘要、snapshot state、`IReadOnlyList Issues`、`bool CanStart`。確認頁使用無長交易的Inspect;開始後必須重查,不能把UI預檢當成凍結來源。 +- `MssqlExportSchema`:凍結的 tables/views/dependencies、canonical definition fingerprint;`MssqlTableRowCount`:object key、`long Rows`。 +- `MssqlExportStage`:Preflight, Schema, Data, Verifying, Publishing, Mirroring, CancelRequested, Completed, Canceled, Failed, LocalCompletedMirrorIncomplete。 +- `MssqlExportProgress`:JobId、Stage、CurrentTable、long 實際 rows/bytes、nullable estimates、完成表數、elapsed、nullable ETA、nullable percent、估計狀態、限長訊息;immutable snapshot。 +- `MssqlExportResult`:Stage、PublishedPath、MirrorPath、row counts、elapsed、Warnings、ResidualPaths;只有已發布才有 PublishedPath。 +- `MssqlExportLimits`:`SmallRowBytes=262144`, `StatementBytes=262144`, `BatchBytes=4194304`, `BinaryChunkBytes=65536`, `TextChunkCodeUnits=16384`;限制算法的值,不用作資料拒絕上限。 + +### 測試命令 + +實作新增測試沿用 `SmokeTests` 的 `Assert`、`AssertEquals`、`AssertThrows`;case 名稱有 `MSSQL export ` 前綴。增加 `InternalsVisibleTo("mySQLPunk.SmokeTests")`,不把整個新引擎改為 public。每 task 使用以下命令,將 group 替換為其指定值,並保存原環境變數: + +```powershell +$savedMssqlSmokeFilter = $env:MYSQLPUNK_SMOKE_FILTER +try { + $env:MYSQLPUNK_SMOKE_FILTER = 'MSSQL export session' + .\tests\Run-SmokeTests.ps1 -Configuration Debug +} finally { + $env:MYSQLPUNK_SMOKE_FILTER = $savedMssqlSmokeFilter +} +``` + +紅燈必須是新 assertion/未實作介面造成,不把 NuGet/compiler/AV 失敗當成正確紅燈;綠燈為匹配 case 全 PASS、runner exit 0,並保留其既有 AutoReleasePolicy 檢查。新增 integration cases 必須在 `MYSQLPUNK_MSSQL_EXPORT_LIVE=1` 且 runner 驗證 fixture 時才註冊,預設 smoke 不連 DB、不產生大檔。 + +所有檔案測試只在以 CreateNew/GUID 建立的專屬新目錄內模擬舊檔;不碰使用者現有輸出。新 runner 只測 MSSQL 新功能,不參照 ReportExportTests source。測試支援物件放該 partial:可控 clock、可注入錯誤的 stream、拒絕 GetValue/GetString/逆序 ordinal 的 DbDataReader、記錄呼叫次序的 fake source。 + +## Task 1:獨立連線 owner 與不可變 request + +**Files:** Create `lib/MssqlExport/MssqlExportModels.cs`, `lib/MssqlExport/MssqlOwnedSession.cs`, `tests/SmokeTests.MssqlExport.cs`; Modify `lib/ConnectionOpenService.cs`, `Properties/AssemblyInfo.cs`, `mySQLPunk.csproj`, `tests/SmokeTests.cs`, `tests/Run-SmokeTests.ps1`。產品路徑皆在 `mySQLPunk/` 下。 + +**Interfaces:** + +```csharp +// ConnectionOpenService:既有 public overloads 保留。 +internal static MssqlOwnedSession OpenMssqlSession(Dictionary connection); +// MssqlOwnedSession : IDisposable +internal MssqlOwnedSession(IDatabase owner, SqlConnection connection); +internal SqlConnection Connection { get; } +public void Dispose(); +``` + +- [ ] **1. 寫失敗測試,group `session`。** `Session_DisposesOwnerOnce` 以 fake IDatabase 計次;`Request_CopiesSelectionAndRejectsInvalidMode` 修改輸入 List 後 request 不變;測試 core assertions: + ```csharp + session.Dispose(); session.Dispose(); + Assert(owner.DisposeCount == 1, "SQL provider and SSH lease have one owner"); + Assert(request.Tables.Count == 1, "Request must not retain mutable selection"); + Assert(MssqlExportLimits.SmallRowBytes == 262144, "Row buffer budget"); + ``` +- [ ] **2. 跑 session group,確認紅燈。** 編譯/註冊 wiring 與 friend assembly 併入這個可測 deliverable,不另做空 scaffolding task。 +- [ ] **3. 實作上述介面與 Models。** `ConnectionOpenResult` 加 internal native SqlConnection property/constructor,在既有 raw provider 建立點傳入;public constructor 保留。factory 使用 `Open(settings,false)`,驗證 MSSQL;失敗 dispose owner。session 不獨立 Dispose 同一 SqlConnection,command/transaction 留來源層管理。不得使用 ConnectionString 另開第二條連線。 +- [ ] **4. 跑 session group 為綠,確認既有 connection tests 仍過。** 只用 fake owner/未開啟連線完成生命週期單元測試;真 SSH 另列 Task 10。 +- [ ] **5. 檢查 diff 與提交。** 僅 stage 本 task 明確檔案;建議訊息 `feat(mssql-export): own export session and immutable requests`。不 push。 + +## Task 2:原生 metadata、預檢與快照來源 + +**Files:** Create `lib/MssqlExport/MssqlExportMetadataReader.cs`, `lib/MssqlExport/MssqlExportSource.cs`; Extend Models、task 測試、csproj。 + +**Interfaces:** + +```csharp +internal interface IMssqlExportSource : IDisposable { + MssqlExportCatalog ReadCatalog(string databaseName, CancellationToken cancellation); + MssqlExportPreflight Inspect(MssqlExportRequest request, CancellationToken cancellation); + MssqlExportSchema BeginRead(MssqlExportRequest request, CancellationToken cancellation); + MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken cancellation); + void CompleteRead(MssqlExportSchema schema, CancellationToken cancellation); +} +// MssqlRowReader : IDisposable owns Reader + command + cancel registration. +internal MssqlRowReader(DbDataReader reader, IDisposable commandOwner); +internal DbDataReader Reader { get; } +// MssqlExportSource : IMssqlExportSource +internal MssqlExportSource(MssqlOwnedSession session); +// MssqlExportMetadataReader +internal MssqlExportCatalog ReadCatalog(SqlConnection connection, string databaseName, CancellationToken cancellation); +internal MssqlExportSchema ReadSchema(SqlConnection connection, SqlTransaction transaction, MssqlExportRequest request, CancellationToken cancellation); +internal IReadOnlyList Validate(MssqlExportSchema schema, MssqlExportRequest request); +``` + +- [ ] **1. 寫失敗測試,group `metadata`。** 覆蓋同名不同 schema/名稱含 `]`、spatial 明列、依賴缺漏、snapshot OFF/過渡狀態、estimate 缺失不是 0、typed XML/UDT 拒絕;assert: + ```csharp + Assert(catalog.Tables.Count == 2, "dbo.orders and sales.orders are distinct"); + Assert(catalog.Tables[0].EstimatedRows == null, "Unavailable estimate is unknown"); + Assert(issues.Any(x => x.Code == "UnsupportedSpatial" && x.ColumnName == "shape"), "Name rejected column"); + ``` +- [ ] **2. 跑 metadata group 為紅。** metadata mapping 以 fixture rows 測純轉換;SQL查詢本身的證據留 Task 10,不能把 fake 當 live query 驗證。 +- [ ] **3. 實作版本/能力感知 sys queries。** metadata 需涵蓋 tables/columns/types/schemas、extended properties、identity/computed/default、rowstore indexes/index_columns、FK/check、view definition/SET/dependencies。用可用 catalog 欄位/版本選查詢,舊版本缺少 ledger/temporal 欄位時不查不存在欄位。看不到 definition 或權限錯誤明確報錯,不以空集合替代。針對 spec 特殊表/型別/索引完整列出阻擋原因。 +- [ ] **4. 實作 Source transaction/reader。** 改變 owned connection 的 database;Inspect以無transaction的ReadSchema供確認頁預檢,完畢dispose source,不在使用者思考時持有快照。含資料開始時再確認 snapshot ON,BeginRead 開一個 Snapshot transaction 並重新凍結/驗證 schema;SchemaOnly 不開資料快照。OpenRows 明確欄位、無排序/OFFSET需求、SequentialAccess;command timeout 直接設為 0 並登記 Cancel,dispose 登記先於 command。CompleteRead 核對 object IDs/完整 definition fingerprint,再結束交易;例外/取消 rollback 並 dispose。schema改變中途不得 retry 續跑。 +- [ ] **5. 跑 metadata group 為綠並提交。** 加 reader/command/dispose 次序 fake 驗證;實際 snapshot/DDL race 在 Task 10。建議訊息 `feat(mssql-export): collect native metadata and preflight snapshot export`。 + +## Task 3:原生結構腳本與依賴順序 + +**Files:** Create `lib/MssqlExport/MssqlSchemaScriptWriter.cs`; Extend Models/測試/csproj。 + +**Interfaces:** + +```csharp +internal void WriteBeforeData(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation); +internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation); +``` + +- [ ] **1. 寫失敗測試,group `schema`。** metadata fixture 含 native varchar/decimal(38,10)/datetimeoffset(7)、identity、computed persisted、rowversion、複合降冪索引/INCLUDE/filter、FK/CHECK trust 狀態、comment、多層 views。assert: + ```csharp + AssertContains(sql, "decimal(38,10)", "Keep native precision"); + AssertNotContains(sql, "USE [source_db]", "Restore target is chosen by caller"); + Assert(sql.IndexOf("CREATE TABLE") < sql.IndexOf("FOREIGN KEY"), "Create referenced tables first"); + AssertContains(sql, "GO", "View DDL has its own batch"); + ``` +- [ ] **2. 跑 schema group 為紅。** 加 circular FK、未包含 view/function/sequence/trigger、不可見 definition 的失敗案例;不能生成成功卻遺漏物件。 +- [ ] **3. 實作前後兩階段 writer。** 前段 schema/table、原生欄位與 default/computed;後段 keys/indexes、FK/CHECK、comments、依賴排序 views。自參照/循环 FK 留資料後建立。資料來源不含 computed/rowversion 值,摘要說明 rowversion 重生。僅資料兩段皆不建立物件;僅結構仍產生完整兩段。型別/identifier 不經 DatabaseDumpService mapper。 +- [ ] **4. 明定 restore 語意與 identity 處理。** 結構模式預期空白/無衝突目標,存在物件應明確失敗,不產生 DROP DATABASE/TABLE;DataOnly 要求相容既有表。identity 的 seed/increment/last value 與空表、負 increment 情況須能供 Task 4 資料段及 Task 10 後續新增驗證,避免「已插最大值」被誤當全部規則。 +- [ ] **5. 跑 schema group 為綠並提交。** SQL tokens 測試不是還原證據;native execution 留 Task 10。建議訊息 `feat(mssql-export): script native schema and ordered dependencies`。 + +## Task 4:一般列串流、型別及批次預算 + +**Files:** Create `lib/MssqlExport/MssqlDataScriptWriter.cs`, `lib/MssqlExport/MssqlValueCodec.cs`; Extend source projection、測試、csproj。 + +**Interfaces:** + +```csharp +internal static string QuoteIdentifier(string identifier); // MssqlValueCodec +internal static string FormatScalar(object value, MssqlColumnDefinition column); +// Writer receives the already-open reader; does not query source again. +internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWriter output, + Action onRowsAndSourceBytes, CancellationToken cancellation); +``` + +- [ ] **1. 寫失敗測試,group `values`。** decimal 38 不走 CLR decimal、offset與7位小數、GUID、money、float roundtrip、NULL/empty、Unicode/換行/NUL/`GO`/`$(name)`。以 `SqlDecimal` fixture、InvariantCulture、受限 DbDataReader 測: + ```csharp + AssertEquals("[a]]b]", MssqlValueCodec.QuoteIdentifier("a]b"), "Quote each identifier"); + AssertContains(decimalSql, "1234567890123456789012345678.1234567890", "Keep all 38 digits"); + Assert(maxObservedStatementBytes <= 262144, "Bound statement bytes"); + Assert(maxObservedBatchBytes <= 4194304, "Bound data batch bytes"); + ``` +- [ ] **2. 跑 values group 為紅。** fake reader 對大值 GetString/GetValue、回頭讀 ordinal 立即 throw;偵測 row buffer 最大占用。不只測輸出相等,也測讀取方式。 +- [ ] **3. 實作 scalar codec 與串流 projection。** LOB 用 GetBytes/GetChars 小段;對 varchar/text 投影原始 varbinary 保留 code-page bytes,回復時指定來源 collation;Unicode 使用不丟失 UTF-16 code units 的編碼。文字/binary資料 literal使用typed hex expression,避免行首 GO/sqlcmd directive/控制字元穿透;text chunk 不切 surrogate pair。decimal 使用 SqlDecimal;不依當前文化格式化日期/數字。untyped XML 需串流投影及明確 XML 轉型/還原驗證,不能不小心 GetValue 整欄。 +- [ ] **4. 實作有限小列 INSERT 與計量。** 使用 source metadata 排除 computed/rowversion,identity 適當 ON/OFF。整列緩衝/encoded statement 接近預算時移交 Task 5,不回查來源。Task 5 完成前大列入口只能明確標為尚未實作並保持 UI 未接線,不能把暫時行為當正式支援上限。空表產生零筆INSERT;若無可插入欄位,以每來源列一個常數投影讀取並逐列INSERT DEFAULT VALUES,不把rowversion當持久值。 +- [ ] **5. 跑 values group 為綠,完成小資料 reader cases 後提交。** 建議訊息 `feat(mssql-export): stream typed row values within batch budgets`。 + +## Task 5:大欄位 staging、分段與失敗保護 + +**Files:** Create `lib/MssqlExport/MssqlLobScriptWriter.cs`; Extend Task 4 writer、測試、csproj。 + +**Interfaces:** + +```csharp +internal void BeginRow(Guid jobId, long rowOrdinal, MssqlTableDefinition table, TextWriter output); +internal void WriteScalar(int columnOrdinal, string scalarExpression, TextWriter output); +internal void WriteChunk(int columnOrdinal, long chunkIndex, byte[] encodedBytes, int count, TextWriter output); +internal void CompleteColumn(int columnOrdinal, bool isNull, long chunkCount, long expectedBytes, TextWriter output); +internal void CommitRow(TextWriter output); +``` + +`encodedBytes` 是 codec 指定的原生 binary/varchar bytes/UTF-16 bytes;chunk writer 不保留呼叫者 buffer。型別、collation、欄位列表由 BeginRow 固定。小型非LOB值透過codec生成的scalarExpression交WriteScalar,一個受guard的batch設定typed欄位並標記完成;不能接受UI/外部任意SQL。先前small-row buffer內的scalar與文字/binary分別移交對應方法,無第二次來源讀取。CompleteColumn用於分段欄位,核對非NULL值的chunk/bytes並標記完成;NULL狀態與空值保持區別。 + +- [ ] **1. 寫失敗測試,group `lob`。** 單元用惰性 reader 供 64 MiB binary、超過 16 MiB Unicode 與多欄位無 PK;不在 fixture 自己先配一整塊大字串。assert: + ```csharp + Assert(maxRawBinaryChunk <= 65536, "Bound raw binary chunk"); + Assert(maxTextCodeUnits <= 16384, "Bound UTF-16 chunk"); + Assert(maxSmallRowBuffer <= 262144, "Switch instead of growing row buffer"); + Assert(finalTargetInserts == 1, "Only insert the complete row"); + Assert(sourceReadPasses == 1, "No PK or row reread required"); + ``` +- [ ] **2. 跑 lob group 為紅。** 測 NULL 與空值、尾空白、surrogate boundary、bytes 膨脹、small→large切換、text/ntext/image、untyped XML、cancel 中途。測試生成 guard 結構;SQL engine 實際拒絕缺段由 Task 10 驗證。 +- [ ] **3. 實作同 session 私有 staging protocol。** 使用工作專屬 `#temp` 名稱;每列紀錄 job/row/column/chunk index/received bytes/expected totals/null/completed/failed/consumed。非NULL先空 max 值,`.WRITE(chunk,NULL,0)` 追加;每個 batch 驗證上一狀態,以短 transaction 原子更新值與計數,錯誤 rollback 後標記失敗再拋出。NULL 不用 `.WRITE(NULL,...)` 代替。 +- [ ] **4. 實作最後 guard 與目標插入。** 同一短 transaction 驗證所有欄位完成、DATALENGTH、chunk數、順序及未 consumed,才 INSERT 並 consumed。staging缺失、缺段、重複、錯序、轉型失敗均無目標半列;不要用 TRY_CONVERT 的 NULL 吞錯。TRY/CATCH 每個 batch 完整閉合,變數不跨 GO;清除已插列內容但保留必要的消費狀態以拒絕重送最後批次。一般多列還原不承諾全檔單一 transaction。 +- [ ] **5. 跑 lob/values groups 為綠並提交。** 檢查輸出每個 statement/batch實際UTF-8大小;不以擴高16MiB門檻充當分段。建議訊息 `feat(mssql-export): reconstruct large values with guarded SQL chunks`。 + +## Task 6:有界檔案驗證、安全發布與鏡像 + +**Files:** Create `lib/MssqlExport/MssqlExportArtifact.cs`, `lib/MssqlExport/MssqlExportMirror.cs`; Extend Models、測試、csproj。 + +**Interfaces:** + +```csharp +// MssqlExportArtifact : IDisposable owns only its job temp. +internal static ExportDestinationStamp CaptureDestination(string path, CancellationToken cancellation); +internal static MssqlExportArtifact Create(MssqlExportRequest request); +internal Stream Output { get; } +internal MssqlArtifactReceipt Complete(IReadOnlyList rows, CancellationToken cancellation); +internal void Verify(MssqlArtifactReceipt receipt, Action progress, CancellationToken cancellation); +internal string Publish(ExportDestinationStamp expectedDestination, CancellationToken cancellation); +// MssqlExportMirror +internal string CopyVerified(string publishedPath, MssqlArtifactReceipt receipt, MssqlMirrorOptions options, + Action progress, CancellationToken cancellation); +``` + +`MssqlArtifactReceipt` 定義 job ID、temp/published identity、long bytes、SHA-256、table/row totals、完成標記。完成標記不包含自己的 hash;receipt 保存包含標記在內的最終 hash。 + +- [ ] **1. 寫失敗測試,group `artifact`。** 以全新 fixture 建「舊版」與「他人暫存檔」,注入 truncate/disk-full/rename失敗/cancel/同意後換檔: + ```csharp + Assert(File.ReadAllText(oldFixturePath) == "old fixture", "Failed publish keeps existing file"); + Assert(File.Exists(otherJobTemp), "Cleanup owns only this job temp"); + Assert(pruneCalls == 0, "Failed mirror never prunes old backups"); + Assert(streamReadMax <= 81920, "Verification uses fixed buffers"); + ``` + ReadAllText 只可用於這幾個 bytes 的測試哨兵檔;產品 verifier 及大檔測試禁用。 +- [ ] **2. 跑 artifact group 為紅。** 增加同目錄mirror no-op、mirror target已存在不覆蓋、cleanup失敗殘留路徑、發布後cancel結果仍有PublishedPath。 +- [ ] **3. 實作 temp/hash/驗證。** 目標同目錄 CreateNew,名稱為隨機 `.mysqlpunk-export-.partial`,不帶 `_backup_` 等 retention分類片段;UTF-8無BOM writer、incremental SHA-256與long計量、80KiB重讀。完成標記只在所有來源/DDL寫出成功才寫;關閉flush成功後才能verify。 +- [ ] **4. 實作發布邊界。** 正規路徑同一工作/process互斥,最後取消與destination stamp核對後進短不可取消區;不存在用不可覆寫Move,存在用File.Replace,保留可恢復位置。目標變更、鎖定、FS不支援replace均fail closed,不退回delete-then-move。以 Windows 實測核對外部競態;不得把process內互斥宣稱成所有程序的檔案鎖。清理只刪本工作明確owned暫存;已發布/可恢復檔不誤刪。 +- [ ] **5. 實作鏡像。** 串流copy到目的地獨有partial,驗證同receipt hash與長度,發布唯一正式名後才呼叫 `BackupRemoteMirrorService.PruneRemoteBackups(directory, retainCount)`。保留原分類規則,同時不可讓其他活躍工作partial被分類清掉;不呼叫舊MirrorBackup。錯誤/取消回worker以LocalCompletedMirrorIncomplete呈現。 +- [ ] **6. 跑 artifact group 為綠並提交。** 建議訊息 `feat(mssql-export): verify and publish exports and mirrors safely`。真UNC/權限/磁碟滿與發布競態仍列Task10。 + +## Task 7:worker、進度/ETA 與取消 orchestration + +**Files:** Create `lib/MssqlExport/MssqlSqlExportService.cs`, `lib/MssqlExport/MssqlExportProgressEstimator.cs`; Extend Models、測試、csproj。 + +**Interfaces:** + +```csharp +internal MssqlSqlExportService(Func sourceFactory); +internal MssqlExportCatalog LoadCatalog(string databaseName, CancellationToken cancellation); +internal MssqlExportPreflight Preflight(MssqlExportRequest request, CancellationToken cancellation); +internal MssqlExportResult Export(MssqlExportRequest request, + IProgress progress, CancellationToken cancellation); +// MssqlExportProgressEstimator: caller supplies elapsed time, tests use a fake clock. +internal MssqlExportProgressEstimator(Guid jobId, MssqlExportSchema schema, bool includeMirror); +internal MssqlExportProgress Sample(MssqlExportStage stage, MssqlObjectKey currentTable, + long currentTableRows, long currentTableSourceBytes, long writtenBytes, TimeSpan elapsed, bool force); +``` + +`currentTableRows/currentTableSourceBytes`為目前表累計,`writtenBytes`為全檔累計;Sample未達節流時可回null,force必須回snapshot。estimator從schema取得nullable估計,切表時記錄已完成實際數;不可自行查DB。LoadCatalog/Preflight都各開短生命週期source並dispose。 + +- [ ] **1. 寫失敗測試,group `worker`。** fake source記錄open/begin/read/complete/dispose與檔案步驟順序;估計用fake clock,不sleep。assert: + ```csharp + Assert(calls.IndexOf("source.dispose") < calls.IndexOf("artifact.verify"), "Release snapshot before local work"); + Assert(samples.Where(x => x.Stage != MssqlExportStage.Completed).All(x => x.Percent == null || x.Percent <= 99), "No early success"); + Assert(initial.Eta == null && stalled.Eta == null, "No fabricated countdown"); + Assert(result.PublishedPath != null && result.Stage == MssqlExportStage.LocalCompletedMirrorIncomplete, "Keep local outcome"); + ``` +- [ ] **2. 跑 worker group 為紅。** 讀取/寫入/verify/mirror取消,發布區取消,舊operation回報、DB斷線、schema指紋改變、實際rows超estimate、不同列寬、缺失估計都覆蓋。 +- [ ] **3. 實作階段順序。** 新source重做Inspect→BeginRead→DDL前段→逐表data→DDL後段/schema核對→CompleteRead與source Dispose→artifact Complete/Verify/Publish→選用mirror→結果。預檢失敗不建立partial;UI先前Preflight不跳過開始時的重查。SchemaOnly不讀rows;DataOnly不寫DDL。失敗停止整個工作,不跳表或重連續跑;finally reader/command/transaction/owner及本工作temp順序清理。 +- [ ] **4. 實作進度。** 預設200ms節流,階段/表/terminal強制回報;使用最近10秒樣本,至少3個樣本且至少2秒才估ETA。連續5秒無實際進展時ETA回null並顯示等待。估計remaining work按表估計bytes及實測row-width調整,不按表數等權;估計失準或劇烈速率變化可退回未知。局部verify/mirror以實際bytes計算,整體百分比單調且成功前≤99,不重置0;terminal狀態立即回報。UI log採上限200條。 +- [ ] **5. 跑worker group為綠並提交。** constructor default factory只建立自己的session;不讀全域backup設定、不觸碰共享provider。建議訊息 `feat(mssql-export): orchestrate cancellable export with measured progress`。 + +## Task 8:正式精靈與 Punky + +**Files:** Create `mySQLPunk/MssqlExportWizardForm.cs`; Extend 測試、csproj。重用現有 `PunkyReportLoadingControl`、ThemeManager、UiKit,不修改報表精靈。 + +**Interfaces:** + +```csharp +// MssqlExportWizardOptions (Models): mode, initial schema/name selections, +// includeViews default, default file name, optional mirror; immutable. +internal MssqlExportWizardForm(Dictionary connection, string databaseName, + MssqlExportWizardOptions options); +internal MssqlExportResult Result { get; } +internal MssqlExportWizardForm(Dictionary connection, string databaseName, + MssqlExportWizardOptions options, MssqlSqlExportService service); +``` + +- [ ] **1. 寫失敗測試,group `wizard`。** 在STA透過fake service,不連DB;預設全選/單表預選、搜尋comment保持勾選、同名表不同schema、零選取/空路徑阻擋、mode切換views、snapshot不足、reset舊結果、關閉等cleanup。assert: + ```csharp + Assert(selectedObjectIds.SequenceEqual(expectedObjectIds), "Search keeps object identity"); + Assert(!startButton.Enabled, "Failed preflight blocks start"); + Assert(!form.IsDisposed, "Close waits for worker cleanup"); + Assert(soundButtons.Count == 1, "Mute only; no play or pause"); + ``` +- [ ] **2. 跑wizard group為紅。** UI自動化以控件Name/AccessibleName與注入service驅動;不靠像素坐標斷言全部版面,也不把fake進度當真匯出。 +- [ ] **3. 建立精靈。** 參照已驗收index.html:選表→輸出設定→確認/預檢→進度/結果;table list顯示schema/name/comment/nullable estimates。Windows SaveFileDialog、背景捕獲destination stamp、庫級結構views預設勾選;確認頁明列SNAPSHOT與logical export範圍/mirror。DPI、working-area clamp、可捲內容及固定footer沿用現有UI模式,中英文沿用ReportText.Get。 +- [ ] **4. 接worker生命週期。** constructor只複製scalar connection settings並剔除pdo/isConnect/runtime state;Shown背景LoadCatalog,確認頁背景Preflight,改選項使預檢失效,通過才啟用開始;開始時freeze request。UI context建立Progress,Task.Run執行service;只接受同JobId且視窗存活的進度。UI timer只更新elapsed,距離最近實際進展5秒後將ETA顯示為等待/未知,不在阻塞reader時繼續假倒數,也不虛增bytes或百分比。執行禁改選項;取消/關閉等待worker,terminal才EndExport及disposeCTS。開始BeginExport一次、只有Muted控制,取消靜音不重播原音。 +- [ ] **5. 跑wizard group為綠,做本機WinForms人工檢視再提交。** light/dark、最小可用工作區與100%/150%/200%DPI、長table/comment、未知ETA、cancel與關閉按鈕可達。實際聽到聲音另記證據;未可觀察不聲稱通過。建議訊息 `feat(mssql-export): add native export wizard and Punky progress`。 + +## Task 9:四個互動入口接線與 provider 回歸 + +**Files:** Create `mySQLPunk/Form1.MssqlExport.cs`; Modify `mySQLPunk/Form1.cs` 四個handler、csproj;Extend測試。 + +**Interfaces:** + +```csharp +// Form1 partial; TreeDatabaseTarget is the existing private nested type. +private void OpenMssqlExportWizard(TreeDatabaseTarget target, + MssqlExportMode mode, string initialSchema, string initialTable, bool backupEntry); +``` + +- [ ] **1. 寫失敗測試,group `routing`。** 用mode/options捕獲 seam驗證四個handler在MSSQL時只開一次wizard、單表只預選指定schema/name、DataOnly保留、backup才帶mirror,view單物件不變成全庫;MySQL/SQLite/PostgreSQL仍走既有路徑。 +- [ ] **2. 跑routing group為紅。** 測到真正handler分流/options,不只搜尋source中的方法名字。 +- [ ] **3. 在既有SaveFileDialog/同步dump之前分流。** 使用target.ProviderName識別MSSQL,不能 `target.Database is my_mssql`,SSH會包wrapper。target.ConnectionInfo剔除runtime物件後交form,backup入口捕獲Directory/RetainCount並使用_backup_檔名;其餘export傳null mirror。指定單表按metadata解析原始schema/name,object_id由catalog確認,不從顯示comment反推。 +- [ ] **4. 收斂完成訊息。** handler根據wizard.Result更新main status,不無條件再跳「SQL已匯出」;使用者取消設定時無成功訊息。本機完成但mirror未完成顯示精確狀態。維持view-only舊入口、非互動backup caller及其他provider不變。 +- [ ] **5. 跑routing、wizard與既有dump/native-backup/connection groups,再Debug/Release build後提交。** 建議訊息 `feat(mssql-export): route MSSQL interactive SQL exports through wizard`。 + +## Task 10:隔離 DB 還原、大資料與交付證據 + +**Files:** Create `tests/SmokeTests.MssqlExport.Integration.cs`, `tests/Run-MssqlExportIntegration.ps1`, `docs/MSSQL_SQL_EXPORT.md`, `docs/design/mssql-export/verification.md`; Modify smoke registration/runner source list、必要的純fixture忽略項、現有history。不加入raw SQL大檔或credentials。 + +**Interfaces:** + +```powershell +# Runner defaults to instructions/no execution unless -Run is explicit. +param([switch]$Run, [string]$Server, + [ValidateSet('Core','Large','Concurrency','Ssh')][string]$Profile = 'Core', + [string]$OutputDirectory) +``` + +runner要求明確server及全新/空output目錄,Integrated Security為第一版fixture認證。若測試環境只有其他認證,執行時補非command-line秘密輸入方式;不讀使用者已存連線或把密碼寫入args/log。不自動下載容器、安裝/啟動DB。明確記錄server與sqlcmd版本/architecture/資料庫collation與profile,敏感資訊不輸出。 + +- [ ] **1. 寫與執行Core restore失敗測試。** 真實產物由新service生成,透過一次 `sqlcmd -S -d -E -b -x -f 65001 -i ` 還原;不使用mySQLPunk舊script splitter。來源/目標DB名必須是此次runner產生的 `mysqlpunk_export__src/_dst`,清理前核對server、完整名稱及ownership token,絕不DROP傳入任意DB。runner預設只介紹用法、不建庫;-Run後只操作自己建的fixture。 +- [ ] **2. Core矩陣。** 兩個schema同名表、Unicode/`]`識別字;SQL型別精度/offset/collation/NULL/empty/tailspaces;keys/constraints/indexes/views/comment/identity正負increment/空表與新增;computed/rowversion預期;geometry/geography和其他unsupported預檢拒絕。對reader不可見definition/snapshot off/版本缺欄位也保留案例。使用catalog比較及streaming value hash,不只比行數。 +- [ ] **3. 大欄位restore矩陣。** 至少64MiBbinary、超過16MiBUnicode、多LOB/無PK/legacy text/ntext/image/untyped XML、不同tempdb/source collation及長WKT普通文字。assert還原DATALENGTH/streamingSHA-256相同;`GO`、`:r`、`$(name)`、NUL/surrogate不被工具展開。對fixture產物副本移除/重複/調換chunk,故意conversion error與錯誤後繼續的client,assert失敗列沒有新增/沒有NULL替代。測重複最後batch不再插入;成功先前列可存在,不把整份還原稱all-or-nothing。 +- [ ] **4. Large profile。** 持續產生而非一次配置大buffer,輸出檔實際 `Length > 2147483648`;10倍來源對照記錄peak working set/private bytes、rows、source/export bytes、elapsed與versions。出口/verify/mirror各自sampling,確認無隨總量線性成長;若峰值差異異常需分析,不只用含糊「跑完」。>1GiB單欄/接近型別理論上限另列optional stress,未跑保留pending,不能阻擋使用者已同意的v1範圍。 +- [ ] **5. Concurrency profile。** 第二連線以一個transaction同步修改父子表,驗證export同一快照;途中DDL變更須失敗;等待command取消/連線斷線、IO中斷/磁碟滿或受控故障stream、verify/mirror取消、Windows覆寫競態均核對舊檔與結果。來源交易必須在file verify前已釋放。設定snapshot只限自有fixture。 +- [ ] **6. Ssh與人工UI。** 明確測試SSH端點提供後驗證同job tunnel成功/取消/失敗釋放且main connection仍可用;缺環境保持pending。Native UI實際大資料作業、聲音一次/mute、長時間回應性另外列結果,不用HTML模擬截圖替代。 +- [ ] **7. 正式本機檢查。** 所有定向cases通過後,保存/清除既有filter跑一次完整既有smoke、`build.bat Debug`、`build.bat Release`、`git diff --check`。檢查release既有CodexVerify/test EXE排除,fixture只在ignored全新目錄;必要時增filename正反案例,不為本功能執行packaging/release。任何AV攔截均停止該測試管道並記pending。 +- [ ] **8. 文件與證據。** 操作文件寫實際已測sqlcmd命令、SNAPSHOT前提、支援/不支援能力、部分restore語意/資源成本、GUI匯入未驗證範圍、結果與取消文案。verification逐項填PASS/FAIL/PENDING、命令、版本、產物hash、記憶體記錄與未驗證理由;保留大檔local-only。history追加精準結果。 +- [ ] **9. 最終review及提交。** 依選定執行方式進行一次整體review,修正實質問題後只重跑受影響檢查;不循環產生無實質發現的review。建議訊息 `test(mssql-export): verify isolated restore and large export behavior`。未完成的DB/SSH/UI驗收保持明列,不宣稱功能全數驗收;merge/push/tag/release需另外授權。 + +### 預定驗收命令(本文件尚未執行) + +```powershell +.\build.bat Debug +.\build.bat Release +.\tests\Run-SmokeTests.ps1 -Configuration Release +# <...> 是執行時明確選定的隔離環境與全新目錄,不能原樣執行。 +.\tests\Run-MssqlExportIntegration.ps1 -Run -Server '' -Profile Core -OutputDirectory '' +.\tests\Run-MssqlExportIntegration.ps1 -Run -Server '' -Profile Large -OutputDirectory '' +.\tests\Run-MssqlExportIntegration.ps1 -Run -Server '' -Profile Concurrency -OutputDirectory '' +git diff --check +``` + +## 執行順序與 handoff + +- 主線順序:1→2→3→4→5→6→7→8→9→10。每task測試紅→綠與diffreview後形成可追溯提交;尚未接線時不對外宣稱完成。 +- 可並行部分:Task 6在Models/介面固定後,可與3–5的writer工作並行;不要同時修改Models、csproj、同一測試partial。其他task按依賴執行。Task10的fixture準備可以提前,但真restore證據必須使用最終writer。 +- 建議分工實作:此功能的錯誤可能造成無法還原的備份,適合每個可測交付獨立review;主agent管理shared介面與最終整合。使用者也可選擇主agent依序實作、完成後整體review。 +- 執行前檢查當時git status與既有worktree附件,依using-git-worktrees流程選隔離checkout;必須攜帶本spec/plan/既有新prototype檔,不能在新worktree漏掉未提交設計。不擅自stash/reset或帶入無關Desktop Properties。 +- 計畫審閱後才開始產品實作。本次只寫規格狀態、plan、README導航與history;沒有build、EXE、DB、commit或push。 + +## 自查(計畫撰寫時) + +- Spec 2–4/Punky對應Task8–9;spec5對應Task1–2、7;spec6對應Task2–5、10;spec7對應Task7;spec8–9對應Task6–7;spec10證據對應Task10。 +- 四條互動入口包含獨立的單表handler;view-only、非互動backup、其他provider保持原路。覆寫同意/partial ownership、mirror驗證後prune均有獨立測試。 +- Review Focus五項均已分配owner與測試。接口一致採Models/IMssqlExportSource/MssqlRowReader/receipt/request;沒有混用原shared IDatabase當worker connection。 +- 編譯與fake cases、native UI、實際SQLrestore/大資料/持續寫入/SSH證據分開。未執行步驟沒有勾選,理論型別上限沒有宣稱已驗證。 diff --git a/docs/design/mssql-export/index.html b/docs/design/mssql-export/index.html new file mode 100644 index 0000000..f490c16 --- /dev/null +++ b/docs/design/mssql-export/index.html @@ -0,0 +1,161 @@ + + + + + +mySQLPunk · MSSQL 匯出精靈預覽 + + + + +
+
mySQLPunk/互動原型 · 模擬資料
+ + +
+
+
匯出資料庫 · SQL Server
mySQLPunk
+
DATABASE EXPORT

資料庫匯出精靈

demo_shop MSSQL
DEMO-SQL / SQL Server
+
1選擇資料表
2輸出設定
3確認匯出
4執行與結果
+
+ + + +
+
資料匯出中一致性快照

正在匯出資料

+
43%
整體進度 · 估計
+
目前資料表
+
已經過
04:32
目前速度
18,420筆/秒
預估剩餘
約 6 分 10 秒

依目前吞吐量動態估算,包含後續檔案驗證。

+
正在努力整理你的資料!你可以查看下方的逐表進度。
+

本次工作

✓ 準備›✓ 結構›匯出資料›驗證檔案›完成
+
+ +
+
+
+
畫面方向預覽 · 沿用 mySQLPunk 精靈操作與 Punky 素材 · 資料、檢查結果、時間均為示意
+ + + + + diff --git a/docs/design/mssql-export/preview-progress-dark.png b/docs/design/mssql-export/preview-progress-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..57901a3eb8d45fc751a6da828578645c56b77394 GIT binary patch literal 94920 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%0DnwjHoT4KKq$$0(RjZ`vO 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

1S)WnZ$dt)K&-T!*B8h_uU>IZ5DPN^{ygS#= zTCpmUuZmq}5JTL(a=wOB^L2tIs#Py>TMP~RRw(=qZ#drl60V=Q1m`cE1`)|HvP^L! z5WZZ!{fETSia-DR$Kihl2=FL3jMyP23e%Cc%*iS(`Xa*O`=)7~x-_u=eMv9HZbGbd zzXII~`5dnyA`^fMuSbeSLWelWoY{>@zp^8=d|j&Z$9#&{P$f1M8Un59w*;?0hllU~ zZ}=|$>-3KQkXXJ2-Jzy&!m-h#wjMSg>$2e_PQ}HLOxnVhe3xpvY_HpxgUmAq-Ip)d zQ>Z%7V9|xK9>sftkTf}oF7-%d&bilA))P%?I-dIr$YLROX`!CBr3_arCP#ib>}7UB z<+nu9(=Sg@MS(}QKVS*Dvftp&*_R=p0```#>=_p4G=rdtEusWOY!OIW7YrP!AaA`F z6BR?TDlE55RD8bL`8f@C zcfwl6ey%J+^jQI`&q@>}lgU-6Fzc7jb_a{GS~XQt?=5BAemdp;&X?~@HuF4klJym9 z(ekrSln?nIk!#fcVUfAX`4+&0#xFr~1U`Dw`GsB)vaPW(E>Ex9o_8a6ZN721afs9n zpvoUI?yjERGTTrk1VnMSR{fIueyUyk4Bo(f)N~fd<)J`d(uiGJ9-gzF*8d}|vek+v zwNIs|FpS(onG};NmJPr^LD}7Z`O2d$rn}U)+)KKwog>_A##u9^Sn6x0!<;FqRNd4b ztl_ExHi#B0!>gZoMFqi)eRPUDkksHM}@U4*MhAR2CDCjauF_6y*|ytZ)h%#3IG`$RGds$R;z zoypd7l%DU75(16*oQEyX-ir-gR!31`A}da=SLYPy!ZQ6SJA$B=Oimxy#tDFc=v z*IKB~rPm89WYt1RZX%Qn8JrX8+dVfU4P<|IzhonaFCm6vdndiHWawjAV!Nz5-<$}y z*68XXtHJN~KAAia*vD!LTcA{xpz0602M+NX3N|e-a`C*t8_;!pTJM=SzCmilLobLU zy;MgH*U)l z%{a0vDl*|7HNu5wR4)RYLHA1Dfm=xd`!!pbS)_hgwiN8}TF--YV{)u*NQI$2M&s#| zF8A>n>0Zs=g9qGJI~QxwOtzg!tir{%kOab-L-bq6@(;AwX?qIy+xA<8B z!#W!MO$7k-9%YCdF8#{+1i&-R>vyjMKZlH*5!C)y4*9L@?Yq6v``*x>4PL$3OKFjz zt^qY~%3kX_rw$$&a21vNAD*=hOX7M7Y(es`e-I^4-R-eh8_dg+t3&IgimckDAh9r9 z;X{W?FQc)boWwX#{-dPhKDGFm^5hvyH^1YWUp;Ll8Xox5+ha6l4#e*EZ;F&`jyYv_GrF&po2)gkEe^)fOxPu;%#BGWsYDt3g zBQjD0q>LN`h*k@F^)7R5i`E$O3xdeaH@e2Y==D&SCV}tD8b--dc9|O;+y+nIMa`Fp)rzMMW% z^j60~Zm5-&e*h-$!Y77Rkt!m=!|1`woxxchEp3iXeEnlddLLYbA8F8(I7$>&h^uSx zsUFrrv@7A$NGNYHxXf#=jIy}}WDO7*7)wg|Pl(v|WoLA<$pz=X5-&bX=XV5PByjzgsjSWyXkHSW_ zH2Y4D6nXJooKJNDHC0co<<-b)U7Fr1lR?_TcLa)QVbMzev@+OtnrF!8(+jmwH*r@B1=TRSc4POg1k)tyH&NVs_@C*vCmLIC8Ms} z-=^?Fe3UxiZR**}>U5)XCEZCiD37AI)yh-cAV2ENoiQCTg}&-OprzTR0&Irgdu3vy zOtcRN>kr_Y$u@E6hAS=@6~mFB3KwiJ|M(K)yNI0>tr`8sYv;-TYF|T+O&SOa-`>5; z^skN1?Xp^$nv4>MjI$hZHY+G|!{@Cm&)8ubx9C8wkO>EB!bAQ2+qz4;d0g<8^ZM@B zS?7J$y3RMe1Cx)c{?M+jwYcA>Wqmgjk{s@@1Vi~7-+S`9+MYjG6VTcI#Ad8g+Q8kQ z2)8Uk7A8*2V8E%IRWd(m-BSrt(_WyCk(?DVBHb&|5-Nk^->Z3UN4V?KNM6to-9--V z#U8FE6(tdN8ZcO49JS5QDLP3tFVCF*u5JBzlO$So3ypn6pP@2Ok1tOfBp8IiSOwf; zJ~Df&m8YNWI8Igv={_ZS8}KXS?9N+DgBv51AOnUA?!D`ucH@mxphY=FP@)H*USg%X zlMI@!%%Np)=Es-XJ;X~_6sA;u!9a2f;s*W!6yh~GXh;thGo;bwa5|(rWzmJUiemvQ zWY<{VO(>C*JMTo zvR-lc34#!1dPa`Ipk(;Efh0`BJRW~;qyEjThB7DR1VX6dSNGjgGbB{4w6L5!S=y(( zHK}=9&zHctc#Ch^?8@if`+4dm^;|QneG^il+8Hf!xiq2?J4jcLPX7iP@Ubnwg2q!g zsA}aE9I*AV$h@kTiZZ%5$xS88i_!>kEvMSb&L>c1fR*o{I^DC>Z?+(4DNEn2N8e)< z-(P5)bkym5aLC=Z96rpMNRugAJ-~9&cA_cZcKPH~l@hZsQnr^k)vAhNZsLVmmn$-X2U1W1~5uZ<8 z!G&l27L>_uadcH2xj7q@eer?6Ig|f;fGtX18Z1-%TCtEg)7F5KCF)Klm^)zEUolfa z<%^jQ^}W~ml=I<%o&gp38OM|EQMXX2DqQfYA|I``DK<>J9-*lF+|$EY9WO!1n3UoY@8S^k#oWI3>` z%9LOzaz60drw;5=fxTA=o9UH;)|Z98@XG%LIIUD@lq<~k2;2Tu;Ge@}u#@@z*IAfZ z{RhoNekLWNJ{?};_4B7w=oFE5u^@)Gl_0*vwS zH?DO+NE)naOlDRfomX{?i;WB5)<$lnRE6DHXeY|?dXFbsLCjJcsn=flIO7nu>X;S# zlo&~>Ca^HmABpQ{RBPjIKWVPf-(kzS*EunLRetm|;OZJ!%VxM}yeN*KC-BE=*+~1a zq)!JuVc@Z|CEc7lf%JG9Qqx`PA@Xs@1}uv6`s^@18nq;1k9DkZoEwM3#=qtk7l#^> zSO2n|9egrxSYc^l^&SLSne0U9|4suEQ&0XY3-+H&+|yZuF67@B<>o!|+oD4$@6t%o z`;H+p6>DtaJV zdwGC76xj1<6pL9djoChYVpf?Aq#Iu^ihh6CfWdSvh0M(;*y@P*F8EV}DiUQ8qAEN_ zn6Ms5Erp%n0i+<%qmupl88olhntJ?`ZcY|C_rdjb0%?dvy%xOLF5D>P%!BY#%#|yU3~X~?Ygw2N>0Oz|&u2EqlEVjD-)jSFxk6ev^$K7(Ohb%4}kne`eG>(&pJ>VGY zZD)d{wrihdOid$ShH57XiirG9S?6kK8$5in<84G)@kX;%R{xe)sBZniAbRfTkx_#c z{nC<0O$}{}fai3=qIwV&luRt+Q`OW%;`ci`Xm^}2v@Lu3+#aM|`|t;Fm@{Mrzv``s zUl5z0dfU=#BgY~3EQqYoCRH;bC0eMz$eR#uMOVUFQesFLHLDxH6z9CgFxH3xgEyDU z-cqsmxZDb-J;%73u@}8-Kchb4us1++8sav8RPw(}T$hx;4VCG?{5eIa(O`Q}7gZFiyUo&_ZLr;zsn4=4 z-Tml%*j303ixGz*T*K7KCo=lBOi`6-;@j<~h?5VamN?ihgCc8m4qxC@itwuQPpp~J zuYIRCmzgL6Vtc=<3mB%_?T6HpY_5qJDSh8%kW`0$IAu*q*nWyd0Y#m{(|GuaE+2bm zIMwZDBPxINm}A+CKV$shqoAat^BIXPnD^z?7V{HLpB0+fPlbU8N96si*6a><#1W~i zNtS+wykl+cN&FhZrzkpkZsHVVp@~)>W{2HV^nA-4>gme<06JRUm6~&T>))}IkYO_l zBnl4FI)v?W+;`rym(Q$Eyq`+89ZYB_UiTs&kZe?= zdC<-mjh&wS(EkQKc4)Fv0*LX@>#KyxjjS-A=Y4Uf>+5&eJ|VroWf3*Unz-{eyy3Sr zy>IK{kl<#DZXS0dO=?=G-WvKnrpviehs6!_WtZK0ap#3%x@d;o>*(}(d|rk;Vg$h) zkmSFy_tsHy#p$*nfdB~_+zAQpR%nnUI0Og|g;Tgg;Vud8?(XhTxVsimxVr`?Xol{4 zyL;}edH2ogJNKef+k)Nz{&bPn4xALkK)8q_(qV$M~fJ_;)cWb2EDG4L$ z>-bj@7(^9wj&(j?PHXn?5@|DMk4$pIdzNl^MuZuXluppmlWpp!>Y{I(Ic)CgG9hj( z9~vIG0@p438LCV0^)Gat>EcMY?JR&?v{M$9qcdnDOr03WimjzYVK2b&9I9dT=qY1D zVc|d-zZ>QqnXFVA$DX{`qA(L-`{HIrH#c`kNh-2go0yWMm1y);^kk=-@2Fe%NeiEZ zyoi4d@O$@M?I*2q{7vMC18vs9RZKGSp+3caPcBecgtHVWvCxslUBy#C_r6K#k0skZ z8)D=y!s?!LbHAuvu4!1Z?<3`ZZ2g?ja2<%_=7Vha7Z*YQqgf< z%i+Ctk6h)@gc4L@#>%ZR>NsW!IFfp zmVI)75(Fk`E6=z=x2s>o)P3dMycjRYk~T2Liu?zT;yOM6mZ`DxtUYzDx!hHW_5*uP z5>xU?_1fq@J!po1O@lHTz2v^|5oY@?W(PDP6M~ zQ}!^jcOxap+w*5DSgkYi4@Z<1-{VYqcdwXKPB-3Z^|J$)fn_sl;|GQUtXZaqrt9z% zOintbl_4dA^b*qo#`~t5s)BOtN+0S4sKYKJ@%oL|at26kBS;Oh3l3>;z$s78-#cBI zHG$_Yr`B#=8dvf$t}Ki?RHQ($w&5p3e<#8dLjk6wF{wDwzEAB_3fz^*`F&G(Ae|57{EbI zvt+SEYK5Qv#2$eXk|ZQu#;H`tFW$f*8(QPXeC~#tIl%+_mdfAxuX>!XP z*Qq6v=@O?us((3_ll>Ra&vH)`RZAG-7beTzVr}sSQuQGdD91(;(%$-8W^M>Pj0b&| z4wSQ$yWpYPh=w;)S2Kc>bU;iQ{)_e1Mv6L?d!m{9LIf3XE??w_fyz0NsqOu6l7CHN z4{FA%EeqcPg;l>0#`yxAAbpP0a|XVHG_Bpb!ES`0t++GW8FLmcln|fLFHPTe^W%2T z-)c)A)Y+YF3oJNoz`D6KR3O>H4%Qy z!SxV`1c89HNYs~Yk0w!96e_Yyv*N_GWds5F5PzDms_5<;L1Oal7iZbh+?43L)AVxm zq99o!i^cWb7_lpw{u2uSB1#op1u4c*15m~~bd#9H@)gxnC%k+Xoe-9~5xQ2KqAEv% zv!eKMWtzAQvBPAG4}Zj2J7qxz(6umxNcT1O>L3gorZsOYfH;Ag6^s`+IXfUm20AdY zqKX(E8Z~4e&_-y&$v&l*6;V^cy?{a|0N;54l@S~pgflC>w#4LuVu%i^&$1QUq%G0P z7pxEB2TSeT_pnAXp=*V|_7?JiJe7iFJ!|YEekH*&50<-$m&x$sg6ISRI`Wq+V#KB7 zI$m^($RXZIi=k>mYgXDx;JUnHf_xM#gq1qUNJ*1wTJ8V{1Ng`mM7_A*32aqrXeW~P zt;tIsn4h5}Z?vVU@8^L%`M_GwGP`?e{osj}8$eZXH=nzRg^2~3g81hC4?Tf^DCp>x zoShj}xXr*y*Z0K*Q{xO2;QSiuDil=K$Q4+09nqejTw5QRQGu-R`5wq)F0zBU3J<9L zLg7H-f5%!TUIiOrx5y0ApRf%-t55J?Sx0r~Sax&4JD%PnV2%C*N2$KmLwZu~3*uB# z=0Wdwt=<2aa*K@-?&gVnSDF;?h9MIdYnZ-j%2`St)jpZbD|Ytz`S|z~B8Wa!K6r4k zDd}-$A7M)bg;$SUHb>lg4qG^y#dA4r)pPpBJ$b1 zG@sSQeJv>i1Ad*n03-H5QHIcS5Pt6nBhd31MG|DG=Dw1=Zhn~`m|H~m3q!APl$CCH zx{mvT0hq5@nQj*JRJq%Uz5xv zBvb0o9o9DwTJtll$$x$HY;ac#vC?5?wr14e5=pa!UQXTGzPFupWEzdRT4N$tE5518 zoB<&AE{6oG|#gr6P0Zj*SYDC zUv-nt#ON2bpp|QcjB*`YT15s za&r&rIrOkYT52*ZJ`=a-2;egYs4>~ib=!^8AL2gn-YO0$;Z8wf>SNfh__q|i+guXn z6s2V934bV7RX>dOVs)>--)++b*-iV;&RX{KF%se0H^14nsO6^hw}dpSOHSUqMWnlr z$e}W`&`RjW`Q4mS2^uvr3nw)_CR}9DHTGPoAG+zP@dMkR1lmXjS3JpCYwF@`B}sHe zr_jRQYtqracw%5p!%$9V{l4)N7h6yc{#Mr!Yp?y>*8SL6s~&FsRs+wAM2VS8Q>g<;Jazq=Nejv1S88`_8)yxRiLi>kJ7mbQa)2sS$Y>Y$G5yySAm^>Uhkm9c{ArWc7 z@$iik+mGvY;ctX~vc`)<@MH0v9)dIXf0vfNyCfC|_y2(_;h$+@rWWTybRN2>p;4+& zE{F>VY&#u>!Aru$U~a!%M>z@)FhB4ol^h#)&T>;LQ+Uy*;&yUr!SDHKOt|><0F)kK zIjz>t=ZsEpDH`sag&I9R$9=^wCm5jrYakPYgpoEsKRjCM`^Zj$-c&1ddp_h6&do=V zabu>6J&T*o>q%`Mi7r=fw)fC&Xj3_c%-$c={iJPjM={WAy11KMd>6xZ6o-xcaHz0x z(V)tAitp}VdkCx*2Xb};;Kxlw0z>=}17dfIv{fjqXe@srUzZoz?23iJ1l7k~?2?_z z7$o$Y#&H7S!9ro}`VSK>o{=jC_^R`Qv}-ZWoA+UGOKdUSq{G=8g;+@s&!$=OhvTv0qNtot<}|Qwf^do zB=fU^Pw2~=O0YqV*LYQXuZf7K)598ST#_dCVG1m=1aPgz2Y%IJvT--&OQ7jXCi(#tpHoO|2R2@qWlu0owexO`;pIfl zzdM>&-*N0hcWe%4;R%_VZb11a*ZhXaqq^_=O`X>AJc2c~7zVXA;-~e1h5}ylvFTjH z3Un}woFc0M%bwZ8Ksi&v&6>2B5h8uWn^bvuy?!>Xb;v2V%Zez_@ih-~nMfdv>iXa{ z;97w&ZL!E7C0*GiiH> z(j0P4plYBOCili}`0j8XHabi+YF9s=>z@IH@fx@O@XrrLRxBD7Qo@*&0eI7)n3ojY z);mR?S~3v}J_PcDKQ}@-9!WqYmj@*#>0yS`GL%Huz}py-G4$zD@jA~jglx~|=JIiH zx(XjS?H1dRhkA2D*gYVy%uW@WN+P9#_FENczKFhfC?DLDt z1*no6*@>Y|Xwp)j4|l4nCI+HNY0VNob2KcEv#c`ldZwVc9f=fz6zWub$ac zlZ>#$kZP0~On%EA!QMp|^(Mflwa)0i6^f5BJ8i#g9<~t?Vs4(IDugsJ3L;)=kYm;} z9UmfI<+~k;1k9Ixlh&{$ErhGBN zDPrWl&BF1@03mpjuY7BUKL(mgm`UABPliD@;Y2bT6uRo>j(q?pz;t%^f`rmPsn2NA zD+#EmM2P{){LC53kF8wMJ&U&~+14#xv_U;gQNu&}?z!<17@&Depm^4cv067{(+0=s znX0Pt#i3+Q&!K73)5N6m-fBYF-Oq97B|3}t0+8NYE00g;y>zytss-wda}1R>w&N7o;f5CoWUudGPEGoJNS6?II#Z?js>l3} zTE<@AQQ-pW-BNQ}L`2H*WMVwbx24u7(s>mp`r>5tTJ+2P#GnlEFB)E^3EPiYpFUqo zrF@lNv+&Y&OR;2pDZgcyn+TSY5~acjNY6qmr~GWW6brR>Q}eZaANObhD&`}j)Gy8+ z)ow~6fp>`#&w6M>g`Q2Y0_wxCCVrww`&i!!Dej-_4?~a6H<=6ZF0UQzHe51KqRpiWW3}#PZJSM+!>@pTVmffGTq!)x{Y1lCy{r_s)z3YLYQ zQCofpB3n2$zFFXBQ3_KI;2Wc1a02HiMFce7rM`ghyO<2*!0I$RsG^woArs%phbRK= z8VrJ{$O?(nT(_>x=2fPr?8?fR4gzCJB^6p&f@y{nnHCdrmT4`!lZ3*L84(49RO;Sn z$TD;woc~1+T^KC?bu>4Bc4kCPf$AD_f^ls52)K9gy+X2j)>DjRH@Yl5e_Q}g{VVAb z%({yeX&v#9a8@c2BPYg%hYOx;RMxJ=U$&Nig&3_PS$$sTJ<`N9M}h4^HIb&vmM!$d z9@gB9@ciAXlmcU6Q8ZNY7tDT!OWE(ML2>A)GuO8vjc>yZeC5pX*`1#bW2Q^xv-}+Q zxfzISlYa)x@mF2HD>1;ap--js{)K6$%E!*`sVnarGr$*egZ|q1eoWZycnMQ(p4;2p zP6|UGkx&m~wgNl^{|63p%&`^kVLorv+7gzrv)n68b4OgFQ~7 zEH1c9`RkcXa&x?>I7WIp!BtCZQ!HiEt+{P_{A&5zk_)wVAG2f?8;l*H0FC69w7z0NlA&>+ZxFO}XsYb-&_Y$)+SPIp+)EPp# zKYnpsoRsmY9|B9P-4(&b5ojMD^l? zg73tGp`593Cj?fD_fAtXUZ)tZ7J%4N8?9JXj_F z7(){P`4JIodvq1(hF(jT%tfulaIm^xVzzA=1?ec+P4z$3US$HImE>D)#-il*mM|NkNkV&t6c*B$!q1x%KtPLiq zt?CE>6|PKe-IeHXT#VXSv`{<*zP~+C>#-t{P{h#m%9kri61@=3dKn7ocgT=K$Q)AvE~74u=?s@pE`O>fhOc|Ea?E;@^y!MgC45Vjd8E zaaqSvP@K?IA`_%~aaKl8=|_!eAsA)Oe@cCH=_nSda#<$C5Lzs08d-O+4(`i2H97{r zx^u>E)7-lm^MXFV?tr#O=3PB!s-ez~7vA=It7X18`x2EItmf7$~F-yWiManehAmvR)N-xd|8F`nzkM zJ>~EGf%{u*-kpD4N3C}x!8Pmmf5c9lq&($6zV*%*LY;b&ZzWk=3p`{XO&_-S2nht@I1&Uqkh; zq52Pt_`klYVhl8?BlPR~a~ga{f7rRWlOnGfSXSQ&wA-|@>MVLTckXpD9PF2%f1Bgq0npu-z$G6EN&!}LnABfAgX_O2dlE~ zhxOC4V%EQ!{Hw?Q+b4{Y12zC#Gbdcv^P7G_|6a+kpLFM6-}PBQ(bY3O*%(xH8KLhV zU+VfdRLiV-FdEvz6YTe4f|^Arm9pFy_yXyYbH5H01DTo&kRH`0(c6x3i4v!^vRd#& z&%HuiHGb=Sty@-mM{K_sBglbWOMHDpDkV*jsjK|*5+nKk_xzm4*HF*$6?w_6gM!E| zkACRsPs(h^CTgmkcQ#qz(EOevBidjGL!9NW_hW$q;Ip6?-1ag<^@Ub|hnl3>%ff<` zApOzmnwbTYt+wch@;yXZI2EPDRtq=K=TXJ?QQY)Jd1?_aoPgqWzFJo2S%^5KTFn3* zgXhz*>Zc6~?Lb^B4C>k>RZMbvQue9QyU^`|C>zw^O~ez6jQX%d6wU;~J$60`Iorz6 z?_oUe0Luj#1O3f;+Btu6KZ)It2Q?#(b6r3U`KeU&8THq1Wubz0<|6U zM7R8`6SNqV*|u1{Gjgc3gYE7NL#=V?m+AueKJx{$l@j~J(%Pr2`qHy2BECQbvay;b)xO zAWTV+@+Yd!r}D0!l~1Rf%j@1Tzn9*IEonzcdIKAD?HVsMZ(Q~=OCt#EblA4Zexm zTm!5=RQgS9$<5EBLt>52j>oISWEdqb%S0-wrvdZMTxM4798V=nDaWF9Z@wi{ni6w! zn@%mmccs`)>A1=Gkinz?7irbO!whP4nqw?>hv&k-CM$+MQNp#2f#i<_mU6S~l#3QV z<@oWtnM|l~HrguHL&73yi|ppI-EX5iC~+oL`|3z+NWp5T{3rw(n>dquR|FWoot}^E z33g#%r=GXt=46~kkFi#&Q_~#T(PDwnR%CAT7a=rEC9#!N_!Eb^)Qdzv7oMM^)@+_14IYFM2vvHl72CH6pJ_YZn|Y*?p#Cw*6g&! z4Z=`=|GMU~aS}gw-4nH#XhZ_&q1=o>$cJE8OY5^mNeSIY55Q^kcnEFtHpd>L)B%OM z?qvI#hFb9OVRH)P(7bPPOX)*YCS>3{p3|%tyMsNTdP{<9WIBK|Qp44eBdl3W=h?o#-UwL}3z{o^Px#ccrb^?2(z^yWkR&LUsa1TBR+97I%`^xyq* z{~7P0kJW*SQo2EkQ{BJ?b85?PtlV%-KCJt*rV@UAY;lTPgdP_)^wcZWwSMdU1kHU@ zzV3kY4+;^Xn&=D1YS@JiGY)thV$lX)svCZUVrNH$x-rM~?i6zL(~YaWyCEJ-*@c6yZ#ln;AH%Ta znJs78FwpODvcc&s`l&+7978KT2n73}rl)kq=E^Q21|$74@*Twv;AiYXOGc2Lu<S2$eIW+bYCHSPxqxSJMSoC;0XOyhdKcP+J8rl z-2V`@ren-uoaGg#mSMtD(Z7xw`Bc+8^a1=W@Qbk@7k>nqJ|ga4pzEE-z!&Ix?Lp{Z zX`JA`p#$A(2VDhl<%r=|L8O{0;RyJ zn;KV(vOYerOMbM>kHF&VD|aQvW|BlM({ldFj7SwF=8YG?ErWLaqzF?`dN)J-5rpV$A;w+pk*?(SVS;(>c7Ck;<;PG4BCVjpA_ z>ej{N-jBbhzrNu`ab4MbNZkBZRAr`iT3R!S!*t{=DJy6y`}B)8Ze86pOqm-+(kR1u zjmeG;(%-$?Y0=3Dm=~v3VLDgbxQ>-Us2LvI0Ed|?EcdKs+@a^z$?lLxnSOTmT7hS!cORbCTw)CPr+-#V9|#9^Avxhami0h?$M7_ z1iW5H0TH*XA1Vtx0TqVjU~;HP%7y`^0&2}M`7-Qy)K7j$` z_tHl$d0K(*GUDEW0wlNs%M+V!-_f1CqeT0@Kq~vw&9pk>u#Nq-oA#K)rB)osnZdH9}j@<`ZN5dl{?PVEmN6} zJ~vN@05AXb3+XX4&pvf%oDzeqkxXccpmdqjwQ$8D{GQ(zYT@j%w zi&Gfgbxc*?*%T!<3tk$C1g{p~DzSyvC|3%^U;hSd4p8mS-O8!CU|a4Lw!&1f)~=8E<2qopp>CPMp@EN zxE$BUpIQ=!Wm7G6%F_StbChrY6%Ve9>(018}u~ zv^JMiU*tBgpgR2=dm5)Uh2I4~Zu#ow74)If=|zWu3ZoMED(y@`K778DSD~zfeQz~_Y zF*1Qy&^}kkToxAj?1Mt0p0rs(3$zlvtLPc?o>E-`v`AvU!is^y!jK>kE@t2RpD96i zdd8m9K`p7%`qNgdw?DTZymuHe7QzdwMSr#aMlGWng93f57cuN>A` z3Hyo2R1qSMSG3-pAQ-V&$uf2Q^5Ao6jR>7by~5^YaoONxJIYYs!Vxq}-XR#WX7)QF zvyvNEm^C2$^o!1cX>^}OiiyJ9YVz~OfMz9k8CKITQp6y1XLUj3J67qui=%qhpVdlT z0V2Glca3sRHgbwbh`V@PNQJoR(`idbztGF=Pi$7zD&<4kO(tg7JE9Qojr28+-5$_xUDF&woiCV)+p~jrebx}^Dd13#{dEW~ z`GR|ZGHp(|b;y~U4K;^nxJy6I=zv$&1Mq;uWaY?S%vDv_4ph%1WwuS|yA2tEJZLu-n}#YM`!PWg*Z_$KbDh~c2R0E}ZnW^+ zw{FU%aq%d8r2Bu}GUS9yMfte%kh&No=BZ5uC}W$gQBA1o{lZ>{Lo|Z(W%Ov5pL=d( zHd>*LoS5yK4SXzt17=>aC0+#_fiG%$k!?dk=Q8TEg$>p!N7(wAfrClhTr!ceMbhy) z`$>XX2C028!jkcMtz4WlnrEgvt^dCc{2YY%gadvA_EBGeb%E2k? zBo*yJ6=OQ{B~`Su2IxH>iX+}o7B~t@c}%|}o+R(QMe-$);z8&0_x#{EXb@wjfy?9& z)7Di6LL3R6gyWa~ElTYEg~_-+u1c`dFM?*8eUvXna`x2hz#@;;R#1+V7hN!*I3c2< zM0E(_JNm9!=R5uJ0mey(RCKR8-^57a{-s5^9hvNxE#$P8E@AM%0Z}l z@0v5dRewqYzKG{o01>1{!D#&>6VccM)+HE)GuO;1_(Y_gbGuf@vNZ|*`cdy-yVZvbKw$Fz zA&DRrwCfXVf@B78!wzJ?wXO(FacEK=>px53)$t|*)Umm$ zx#6PAU)QRrsZ0&Y`!%PJ7ntoho}h(E5P?X^he*-_VpEx~_lXf_6!2E6n)b!0{NGd- zSc!4d-F`OTwS?;z5mjn!UbrxFMTcK+-v*vI3Gqo$d#DFMzXBJD!=xpX22v$aD=C^b zEXdm#5oM^qT}=19u9g1%q5Y6YAfaY=k8YjCWr4}rOGQ>9(0-V|#H5QGYpB5ZE=Qbq zpoOjGqs_fi8@Rj(LdJJGp&c-(2J47dn9-}B#U1C4uHieuuKA4i_0-U=e@-GUbUFMD z1?N~aGyPISg1(fWY`{emLo&73GaqG<;Y3`01!l_Ex5K*W$(oadHs#BQpHOx=O$|H> zlFKYeW%eo@DU1`Eq6=%bS z48#t~#{~OGr?X5WzxKDfa-_;e`i+y0@y&cS0(eqc87Sghow@6t~_}*v6*SuFy%gV3eKc7VvsZc7{X_?^R7X)^3x>gpNyo+XoVX z3cDaVyKhrAtP8>Ntz1%g4GH37n)2UAfI!nUtGOaZz$Y2$nwgUv!GM9|mDitGKTv>T)UKnTj%uGlsQ?&hl2ob@IaBuM>KJAxC>R&7m zm4gP~w_pfZDnn~ZqANm7sRQ;DC{g9xJQ~x^DY>}!kAOk=*$qm1;%ycu9{tN<==J53 z?TPC4FBsup^_BlAY31;hu!TQxX-!ME&l+UZ^Lu+Pqq5D!EG&*k>nK;O$Dl{|$IScC zK`g1@o5V~tc{Qd=v_oMBnYll3)sg4FF1!zEZ`hv&pXHy#w%_E-J2OWrTzfMKdOr}p zAd7$CXrFVuLGQQLw*SD*=zI=VTAsgtuXfS%f7?1@T7=}ddxQNeokm&&!5v<%|eW%OvwJ>clpp|ZYbk-U4lGnA^YyLYAx z^3{Ry6xsM^BM;cM@Ru&fK7_vozk71hfcF?MpXM7appmz_5b6W##W7AKfoC;3nz+#P z`6d*IpWksh6uY^{IuVx0idZNcv59*!f^MZmGP9tC>S? z6AO|_EP`~p2Whr`5>U!M0==ZU67`BMTaI>iJPI$zJJp0PDSn~F~G5qcd?)|V&X&Mx}bfAF5Rus?`fU(JSwm#o7oGL1b}sm zczC)@VEVHT9WVOV?6Cbooy9e#87`}lkPR$lo}&IYw~4I$i>(H!xQlN~&(Oq~u!)E# z>2n;W0Pna2U^@_v5yGGyR^7sD{yQUJl2ShM3lk!K*-d>XC4D)Ezoc#MHhBwTv6d8N z{TVV6hXZwoncDXpY$W0W2kmRWy{;a6^#?ATBuVI*`PQcxzOW!_fRmiJ&aYHT&!-(< z3M-211Os2tJLX}#me+#rA&vEM)UP7;owMU+Vr?p8G79Zk0CYHxqx=H z`ho=@(_YI|@w1FTyr39rp_ADHT4DK%HrdeEV*zwN#y3w;%B0AX9Vn+V2cKOF2>%ZSPI>Bbpbl*h<5(a-vZ@41U@p=+O zkv#JZxljr}`7y>Ts#p_yU2;g0fdA}W=84|1-63*J5S<=359?D3su2JY8D-FvpbrjU za&TBO!>m0&xQW)oeVwOA+N6Plopus^UPttWW3ca0x?WM7_9B^2+4Stp<%}DUtgEkW zZs2*T9G~p?+GV2UW+{DGpNbPWtCo^Xzfs-DNx3K-rTydAZy{oOi2@EG3AhF zgwF>diWA#-Ao_l9i}$q?+<`;S#P^q6G{oI=K5`l<=r3+bcq6Tu4g5*FJzHglZUh{} zQ5+f3**XF2NOfUXv+J1Ldgp)O6sr~o^X%9!Qk3$0itB`8b6OR}moEwcsT}9BWMY)* z@}D%5s~$Yo+e87HdYHO`AxFBj!U z52GCX?K%@q*?}XHfEb7R9=G8OvC|A?`r^HXy%A<5(5t@c#6x2Nt#uY&bxobrL#9Ak#X^?kQl=DyJY6l)9y}5^!>7G7Zl>}3JG0FT^syRSswFvA-o%UUE$mmU;J-Nr zaj=TQ1n92ID?;R7$8C87?1_CgDT4sbJ!MOBnYUA~*@iR}Km5>9Bh%9K$j!@ynD4dJ zeY*~W(8)+?zpbl_G$VCv>_vR#P<}rtEwtgdoQzwe@+CkYTQCMdwq3pnfOM?%-aG6A zKgk4q->AbHA^)HxDD@#M!ZhXVv`rEpRe_UjPbfE#uHS8By!Qlx{7G`lDGsOVEV}Ke zUV;puZdX*C8(z7Z&|B9pi2F=#vqiSb4L)X6++$TO%2pGSG3f$pf*(I4BHjBjgb&tC4h_q zrle-WT!;9R2Glfh$ZH@I$#x22P3h+i_Qw$vQR^GGI;+=6hqf!^P_G>*i@{=WpCR5L zLyPFjfqFmwVX61U@hB*yM(%RP-yECX-KB$=ehdm5HK+Nby@gKM;ym~3= z`;?|{FE$z!%QxXhl z0`$%_3`8V9tZa_(7hf8vKvk556mMg|lGd9jUOUZB0vM^d14o))s(A%wu?3!}%Jrz+ zv|IpgQl~mcRhbWhUr5I)mr8oHzgwP4APnjWchC}ecK1M)b-peLvLI4c4a_wEO#W*X%-#4u^gD^cx`y5ju5~+r)KDa;vGXp)Q8KdshGqbc%z0TyC>j201gD{+li31xekU11y%65((65rEr z1bjZ&^58(QHO_aJ6n3?_6xlu99J3I~CvZ;ra=WiA=VYSG)-!bXZ2Vi`g{D?%G{;}|t(&=~VX)|z~W$`rT1)j6?;bWTi6k`f0& zg0VYTu$P>F|5Kv3qo9n`it0Ol;@>HsPirQ6ZS{kqEQ*U5w5Ief2H4j<*JGmDHYx*L zw}7rnr3wb~kMkZ}_OOHj&R}a(paZdOqg02jwm)KNC|07KhSckrfHjwo2yNQi8uXL} zyzXRZR_GQQ!f?EHuXK7g48H^Ut!2Tb4v?#5Mvm2lOeA(39=dblCbls4OT$;up&Z-? z;7P&v-s1VLI_xT`@k6?t>Zsr16`LRXqp#gg9VQ)&Z1C#q)w_D_@EelqC% z%bND+lF<)R#&TkqbaJGAm4_3zXr5~+pMksV8=~$_#FmFuh75yu=^Up{I zGjFhQ7h)F_dSK7kWKdcvNY5|teG>QCgGb7X&FGe z#UV?Z=m9rN^fK8__DWtKMx|h=Bo(1`UY1Lml`FUWu zgDYHUBa5nu-fMW&%+|f#Hh=54RFP>UW@c>n2j6PvWM!Dnr8RJ|x=5U@!|#4DJ86xx z?=*dU(fH&hh#q5s9fzsQVF?;>34(c4G_#CcUD%DIArn&o?w#=Zv=eexi_ok1rD_3h z%v6Mg2!ioD*H3!-06&UNG}1k?%j*%jDLuI6Lk21M{~Y?EXAqVYM({pB;i8r}Kwh_b z`taHUWL$OV@m2a%nRIu0`WJ9YaoBdN73ApNWs2`A6a%Mz1TwBRJxgZHxKtaEe=-Hh&SyEgNR~N)Dq-o%$KA=<74X@wSN5s-`^9;Xb#(!mFb^}Jq zTA0$7q_wE&{NSD+)c~fH8G_;aeV$bJ)d!n^wWkt|7fE$dd+BLHB2sP7ZX^dnctN9< zA}Ji|)y?T|qf&VkKwdR@Wp9GA{=xD64_nb4#@<^tUBg_IUZa2D`q%uU#LM(2L(;w` zAajjd*3|#txTS~!)N0w>0y4j>msU238mqod+ZEN~goKZNKA_Ojx@Qr(A`d6;>|QJh zDxqgbBHItiJV+CXP(PJPM({COekp$Y8TtjIQP~R>)%Le}2?NPzhWuE22CcpA@a!MR zhWgaw*e{M`VyW)$c9*K88myZ-NAVj)cT9z@qAWKe&9rZ4#Xg_1+uu6rycUW)TRBS$3pA3gp;u_tZV9zYh89E8=-< zFx(fu%M(%77?h;#UOrLWlOUV41#0&nF`t0G@yAvfuLfV)wpat~t2OFpW@8M#OpTxm zq+ZIC=v$;|-HcBq-}#!}d-Q^@#}etd*GKQ|zp+MFOAZ0&kOcyF zXyA4s3qDkJrkF5oY|~N2b{_mh_P)I8uI{a(@HbcC1X(bv74}}VnaEy}HzDBIt8X5W z90`CL9v`?>_yZO#8DeoIdpAU_)|?8nO8Qm7iPp;TORyKXjKyv2=S<4Y2kifRf&Ygo zu}uJ2i3F4K@)h)Y_Spurd*oSfJZRV9a=+VB_c4*tW{+~_-_W&Qnj77^2g3B#P4 zhKUam>Gv9&9nL!BssEM?Zb~*`Aox0KAK>;_yG_~r+>y24dN#r6ixIs|#-t^^n^KUI@P(RM^y&fwBj9V# zsm8)=L^3I_s}p)wj~%7Lum!6$dJ<&<{AAbUROQ8YZoeE-zV0**V6bES!XneiK5VV^ z$kQ#3Oa0o5l9ZC5vb5ZjZ|qfi_xDv&k*2Gw@}irItU<5u-L`J)4jd@f@*fWO*p`;7 z*-Ob`;+)f=u>|Nn1qp3orM{yPoJsYt58Jj@U6j)=S_P+sXHJ+tHsQ>IBIii096HLV z2UbvZE~@KvUWdvcr&7a11LmU6bAIe!u>5k%gzLpO+QjX}F&)gjU_A>FLeitO#`eOz z*y0DjQE}jcssh`%f`D4QboR%`srcN_{C*o)o`fOWFBm?{8{{BF`yk$7e!m?ofGA%JO*33*3dAnwHJU1<>!X{iL7SeP)`$^f^X|#5Ev)zw{QAxc}FAS=` zXsZRPa1>$|XC!1NjxcfH^XiHiaLM6B=+R`~jDeFt8OnFdZ*Dp~>UgdcLR`xdwz^+NT1!-G^{2NyZJ<~u5E*tgwq zLAa}m2icvBstK(0-*E~?M0aO1;AY7QLhe6sZQjyp9IbY&ld>fd?52C=fw$rxY#g#n zPnv$pKQauT|2`n%{S|T4yR_3ZUA2>@GXJDOhej3I77Kjww{Mi+sWmCVd_M17opTCf z7RJ~816M4qL+8%P?gk@wG$x`rOyQ$nd4XYSxiigatw;`t2eTZ)_hv-CQ=^&4mc1Hv z!R&w%D0e>QAMa9#(B=1T(LR$JZAU(oyLTMux~qk5)C10Ept6FpV!RFk@h9x9mr|o& z!V5a#ZOVV1l!)naIXpEl@}3E>sHfcaXbVsQ#PsxxA)kfpsE+26u329fXGkZnF!d5N z&sD*hG67I_Ck?;`zw2cUFZe|$M|_>tF32MyQ288!ZtX`HU(z@KwR&5HiJ(o~I>3w1 zSc#D(JIA`Qdk{JSKf2^*3k`eKwTmMLrwRsTpjsHQlhP*EG?7RaIAa&yIl7M5*o+kF zhKc^&5ulxGd_#*paN~{JPj8YPkl;se1g1@ae*38BklNrQHB34ZtM6Q=8{XUu&x}Un zm(t=sTy0MJiMr;Z;M>X0uO4TT-VwL5M?Bzi(D@sTvbn~|$0Mm~@iShMB0z-h5 zM*%wt=>=cK93A{(1{jR2BvxFPgl9crV4c%T+$|$oHHK5$r-0L5R^8&Cw9)x2-usSY z@;}p=lv23=@|)xjoTNhWzdtO!p%hhPYZcNG&z|bv6~nb!l@-Vf+6a4xy84-BjzS$? z>O(nVFFfv)({P(#-$4Vc&{(EeS510~Sxm<8W5$~ru3fdZP9u=W&mmF=0xF$0oxvHN zSH=Cd%OV(kwucf(DpFFEi--1`lcS^YWp)@WRm`u!+B5#;IV@AP?l{4q2jLJ9ZtY}` z%op4mNbHyy4Hg@}?9ykd9SozPs+L98{;t_6Co}}(_u;1>3;S$~N)N#(=JW-&F$OFM zBK|Ujd+&g-wlS(D8NR$KtGJ3@y?zD;YJc?nZVr?M3sHZE+YU-~^?t}*Xytvg`syOZ z(S8GWrpe)rQ$+$Xgd&B%S^bE<(6_h7)R4g9|6uQ}quT1)eNiadLXhGlp}4zi3&q`C ziWdkTv_MOtI0Sbo?(V^gJHahTao3`?_q+Mtz4tkH-!0>uZ|^bgxp%z(BxGi-WHB@6 zT+j2&p9H%TfN-0F#ob$?r(MWQAN(EP`rNZjgNs-sU8XX3Yl;{y{P1f}P$1&};fnsR zZb|=6loDBOwE*1_4dViU0>&#dP4U&*sq%I(j zvWghPoplN>9WwS??PV~i{rB^U*0;bHnUCQUR;G4s&vni$xIWIyYC9Yq#W-boDiTpZ zbJ3_Gtvul3$yKmo$yH(}D?d9X7m4DE5#BSY?NvE_&T?pE{UKmolYD92tzbR`zu6S1 z8T{q#$+2ZpM|fL`&WOSCoD>3J8*)xS05Mx8UtMAdjWC%g3o*%x%rA|)(c}nafC;CI zT`>2e2{GnumebUnr!vXrir0JJ|5x^O>uYEg4rmFXU}>-!Y^s` z^~1#TjNm~QT$+Y>hsDW05Aup|DDqIWCB9QZ$Kw7(PEq)PtnnwkgClEYkA@8&h0vY8 zu8R~y=>Wc)-+>)$n$U_GFT|O^tYB2qkv#mBI)Ir=lCWVO9R8fy+Ne@C6@^#@Uk6 z=v_Un+}jvFeuFE_e3>ND5USB8R&=v0|5aGe%1>-fk}UrHi>60=@JnEn^&D@HO7vK< zvwGsJU~*zfBevihR&J)=uDWL?8Ktu}M%@yYLmsgT9oU@`HWihDH3SV5=z}pUxj{@g zjb~?%?->l6Zyyb2w7y^ztwvXhvpXk;YJ!WVB}3g&@blj8eRz&)wE!B0;)+UKalz{E z*0R+s6_6rc5L-NPr3cqWKNTxstk|Sa4Uc$pE>`c>J0Y-(IXS9ev8bu}C2B^sBcaJ3 z*wX}dHpHcQ z6{{zV;T30I2)Kwp_JxSY0_rNL^R$Jt0A<5l>4b%QM&>ZWiz9J08rJ1v$ty?Q;2`=t}x0p1ikt0#BQ2UI4 z+V@oLfgV<2cV6G-JZp7vSUyo^lda_=vlbLMe&GOe-P@kigDVC7h-t`oklM#kmWtb0!Sl9jKktnO?sj{aVc`%&kDfKdoD zXjSd3Y;wf9q9lnR84 z-@4M8H+K+pr9-Rgb#|wn5-(pwE}l!hBx@$+={>r)u7pKrGj7d~INVIrt_zhQ+E3kq-$a7!#n(-oxe3B>yjLb)rrV#;BhS21VqrzOUzz*+?N49e1%BDw8|q#8T+4sQ|lfP$&*$I9eWg_ zLS@AvT@!?;ZW^dyC>gk3H%Lp9*b9FKryO2Sa-MM(V*JBNv^9o0h9vuW4CSD4QN608GSRyA{<7+n{j;ftOTmWR*0KV zc(6<1Z4QN#zYiWJqGlkEw^FpU*hj?)N;OS_kqhKQTgoYJ4q-r(n%vq&-PJ{R$Xfj3 z%uEgp=}xOXdN(7$tyw%$M(Y$sjkb}?1fVN=EokyN4Z;_T*)B5fN8 z3^N2MAv@X-&$gx*HLdeY6}vdkWNa_si#mzL-#J0;Ik14Ds5(bsNN6=0tz;s_m&j)f@Q|M#ZezjMDXF5?p}WY8HILv!cn7eQ&g^yG_%ptq^VbwtM* z4OFoVG(0mo5fky&n`?FWZ0XruOr8xU$oG&r@09H-TK)AN1bk{INLW-^lt+zk@AyUy zX}lfTEZ{{=mqgXgbu3u7pdtR8-}K?JjH1cSMz+G=thQw$FKoVoc% zjjy5ht?B6fg9#n_3PMqOwwp@`vyJ0P@LSP|pwk?MT4-NW&S}yNwR-TAA(}aBFC(=_ z7f`+K{4_~^>b1_`SaBR;@<7~jW80C|jKU~R)FBk?6VLpJPkBGj*Ph&HdCT|m3vp1? zgeFnvEvD#Kg&S7|NU(E9M+3mcj5JlSUSAW;rD6VyGa+0xWdoii=nX6UMP!D|u{O^~ zN~)5777en!OyT=Rh3~?WH8~RL(4-J5E%>a5IBIF@?yP2j{e`;0FpAtN1)8}h7az%A z9nmh|;nWv`;?((2M9nWX=A{+)pRHxJn{w(9q`}KxgM=#v0HS*)!?~9yUi-=Z!k&q0Y9`2Lr><8nkd3O7_qkzH>oMzUjfTGVRmAfAE?^CO&At@QmYf{n;z=PqSQ zZ~g1855&Wn$~1HW_@r@Awykh-8B!U<cQ}MZe zq|YTNB&jqFBTP|MH~j-1Z?yE7XLg%_oWb9J5n}yc1vtNcB5}s>uPz}6nbmnLa=+sJ zp~|R0w$ltXD$Rq82HE%OP?pd>y{j)Ek+im)F?1~3HNTL*dC8KOgP}TD>K&EfpmIqo+;rT|V zutCo0CFI^0x1Y`Nt0s-W)4=PN%iQV&MPMOh)E&K`abwVRqRu|a88laMb}>6|nR0uB zOJ7|T!Dq`q)4SC0z*HvyVK{U4y!k+cVe)uD7rrbTF+}n?OUzbaK>!e*xz=WjhOKZa z8e1oxcvqcs7VGWn`Zj4~EL(yG;W@^~e#5vJP##a`>P|WFCA(+BrYl~@tu$n?rc1nDJm^08+QDH38T$&m7y=iC zVNVIh`Bb2#4adyCm5lT1O(1Du;y*V+q&yN5Yw=;QtID@eOEu;z1>dK+aV*p4D7phW3WGKuJ68_ z)Od^Cjs{YqdL0hp!5y{@OSD=fbcO|aeY5&z_L?b)*V2I~>4xM5Fh;)G>~CmrJL`-K zLgr=c0 z{KEJ7)lE8ASg(eyRfXs-!tM9?0phHiT!3_LzKt}HOy8}jB!Eq%D+Nbxn{!YS-KQ&r z%atXcoe~C!5W#~UJF$z|fr0j)kI=(?sX{@<%ux5aU+I8xU)#{irigPIVz3>PyapJ> zMBv~G^q5x}MisXPWu>_eO?@FyaiL|kqlWOICVnmP(n{YR5xLP9Ksm4NxxZ6>4j8|B zZMtf4@3%t}Il{CR5#Oz#MKRI)+q%6Y1Z7J%VRIwvrLwFzJamKB(Qo#8 z5}bL(tcR9SO&yMa{WDJyEWZK$?h*a>s$>#Q^hJRQ-yCf{a-t2t_%qmYL1mxRlTFnRXylbX*4ptS`zp%K4J7AM7hCZam0c} zr;7djlbQ`NJw3zgf4v0bLjIV>+5OO0p87BL@y2;IuQScKRkme(LeFmg5Do;DYRv@Y zeHfk&O_}hRWt5YXA4@Mi@jG$ZwRpgFh!V0gB42Qd@-7%DCNF-QB&a zZJOO!xPH5c{WfGMXq4axw|->duBobZhE>9{8F^!Cn3a(J!&~brYZimhxLn)fHf8zs zBcA}Qq{@;zGNIV~xQ6jVb-&N}ZmvG*91@q5l@z%$dId;dZ(CY6Rmz!4_jCq&?l{a%aX*vI4idcN)(sP zlH7}Mn8CX6u9pH|;(mMcN#9COpNveu-`~Dw`XP{MY*K za9EYC-4JttF$a)t({o}N18*Ql*4n#wTo3}Yf_mh?@$(tfS+IG8k+b{8<9prKVXpa% zuW3f5vSyg=<*Y5YGy&XPz63J#Q5kRV7E>QbViu8h{Ozm4uj@%sErZ zfb;1>ylTvd-kJbMak$aW_(6S#PX8G^(vSMLeY+95 zZQ%xhqS87~?I^=jd#NK!QK+BKRD%2A?KmLoYo)T~?uQZ(p68brw)gz;RQ+tHjOipv zy18?@eVPek1*;}hoN2JFjS*dsl8>0#156#%T+W6#Y1xiU{f@gS(_e^&$2gyQ(4s>v za54eIaoVM;14Zl zM5@|OH9b!9y)>@Rc`ceTci4S9jh^qMEIh;cFvRE*H*J%(3e5ZzgM*jovBZu0v(LP? zmfFF=gFurE#Q)_Pqzsl+6gd1Pt)9Tg7m~t@{LRiDS&6{-e%JwyaSdjOd6~#Z@ilcnqBaM|ipu-5Sp)6X*4&hzX-|%U-Nd>#jINiak_x!> zKJoLyVXiJ*5{N1Np30{a4#xx5B#mnM9hPG78c%a|>5c#Dyrg8Xr;s(OO|DaX$ z+<9rSZQj)Vd5li>+v^PLz$PovPab?F(*ehM7}5(#F8Ri1(aJ!Ej1e&u{&il32SOz5ShUyh* z*Yhb5F_B@^#K)ND{$)vKkuF-f#8#_ePUQi+A(n!hMH=mx)jC0LRTV@JjWPKq$Y_*6 zXw-g*2cH`smoy~HGAG=Yv%$)%&KfuJufYrAHm6gTu;103CdjI9s1Ac*@9$s_QZvk5Sr z#QSUX)uq6)`BmXYEOt$b9C&8j+}s3R&n`10iH3hYZ-t3Fe#YWjS#o2{oQuGDzCv>@ zEz#%K=jiU=T!iQBYzg4~)_JDwn(eCk0+UO(oJKt zQQAUs4$*dGfJK9cQx0u8`tuWthC)KJo|dXIXiwr0u}Rd-4K#t7<< zO5^@pWSxC@bwC;=GH&7Skp(z6Ra9qf;if^1sr^>J6Gz^j5S)UHns!b6ot$Wdpur?OHCY^ zjO<6P4SzWq9Tw&{nZYm_5h^@K1BTy3Iym*oD|!^JbyO;|dg^RGvzMjOFw|P_i-Tx< z+z$EfXUkwNv-Ew%V(tr|?}e-?w((6=rk1L*ZHUs>0P@YHXuh=j?y)a|B6Wqv8dI7r zo;rpLn#!0pQMV2A@+2n~BK~sf+>?{xd#`z%yFt^K|jx=%O?fHAu4xgngmcOSfv&& z0~putR5iuC4i?8qTL?=5!D`FAEg|1cF1Q>;XiYUlXcLq%H#?Y8mR8r_>_t_6cKTcv z3eC$Z@?a1<_a12MoNOb&tPzhZE6ZZ{`zk9XZD*#h)6wXo@;y`lALbra(i>r#V{XZb zm2M!{n43+46gTOjf5&>Yo_QCC*xQmjo8pGbx!13UW9RyLuW-DO-1B1-M|u`WMPih{Rqb?5YC zIqaMp4V6W)8sH&Q!VQOJx1un2@A1LG)+UOb84z9$_clWw*wW>_hf7Ub zUx#@O1J?6>$FJ;Ng_;6%&VosEeD%TgSJfJUs zfW5wTX>NE;Tx4o8`7;B23TnpX+kAxHi8V%!QA%%C!edQ#P(+c{lQWH9mmUZ@M)&g< z;htP-ej_wmPp)5C`L?>;>kg_adD-g7K+L%DZ*04S?vr5aU+~CwoOo%i#?}&8V*gu;9}Aq{X{#h3F?~dg=V=b1k=!>VTX(xH*{232fD@(0s>T(?O$N1 z$7|!azf7#0l$SqyMOu*`?NISR5N#+lZM$$i`P8LXI6yse@&fEc{bQ`^Bv)JSphIV= zEn~PIZ7IANyBQ4)4S@5yHkuG_mt=^e@*ve$kl(((f`;}p7>!Wc02rw!T=+C9>f&RF zDC&O?pk4#>6PeCw0+d;zI?5vj>4qDq-*rT1y@Oko=Z3J15s%8EI|5vK%Bf7yT&l6! z9eT{8^*c;f)#APyh#|7n*PuzK2Te6Tp&21SOPPjQZFz+p(S0v%e$Fdp6ml0PR#B9i z7{39@l2zVOYVcTCuS{L^G#F>htQYEoyWrzTnqF8w`Ek8dRR|DkSbyy-bJwJ?${e6- z@qz^374v|0#JSl1(EZ2yg?O>(1s$nc#QRmYm6;{o2@=Mr)??y!@(7PZlY@gp-M~Sa z{4?w7$?wvoRslfZ8kkefCX0p!-NC`}P|$-KIigM$om(>GRIy)HQQ_Qt2{EAybk#%4Qg)v z|8vT`%rkOHtOfgequ_Vn|Je!nA6b|QNWi1&c z>G*_!ZZp6f7jRJUr;nu)irUVKTtZ#M#R;3*G`UfY&yn%&5<7HZ7|rn%qnD8 zP`5BK)|8BQCmn3*9qnBMZ!wkBGEdQFlUT1Z53%t*>u}1khWQ^*g_$+qJz-gNobq6T zJyHGr*64610i1b3u-gpiy*iIrm9z(>_2=rxcy|AMBV}yks}nB5O0w?Y4S$sc-zn;v0^t#-jja(1K{ z%ZGL-pV7cZw_iXLiKb2YTDR*&>(L6V6tNtR+u*MU;tQp_6HB?2c) zLie5n3wAE(ts6e3OPQ3h&?PE--6U)T!WdZD*#dloqfc>J9%oy%c%EY}5=U;1%jz5# zPD>)Z;Y*!hh73Ub$wyZVbqe>lDS_-}o+?>COjhY3E~HZ-Iiu1UD#K}M>*#l`jIZ=W z(RMwUH>H&BNMKSyn{SL(GL0o1bF4gZ zyImV#K5pY?;G+_~BOCPI7}bh4Ojf{e1Fs@lMzOM5D>R+FcwVyd$w zI%cW#nc}F3!un2q{7A#X(EFo0OLAgZAl43PPqJN|@P3&%rbLF%RCi zvlWzx!#B4-)7a?UovWcB6A(sNTqNd;_7L1#vbKg5b!MJOJ^43`i36k(6T@e~$zG+41P%4d@eZ-VQXCrGW4C4^=kSH{Tz{{_*)W50gx;cf57(Ja zKP9#GW*cu}JXLmX8wl~GXJ^}Ic6%4b4g3C;khyi?Y@eApJc{~_+ z23iMb+#Z7lIlTRuz0l%P!Y>w zS_BYXSh6F`7fbN*Is}*iaa`ZT$2lPmNVHbghj?^x*QHJ6RJ2;;z$jUT@J8xY#nMo6*zgYe8 z?AeJD-99H7e(uy)#OPEoJ^wa?4QJ=acKX2}9TnMTN}Crjbxt7xWmDsEy2zoY4h}r6 z)>l`6VoBiah)(6M(FW)ItY)|(!wWDdrBP8(yQK#4!gFI&>x0teq8pm}h0bk}2K>#> zzoFqwi@j+w1^3ohs!4#nB?$!P#?KexDxPBtP5?c-Ygp*e64nRPjrTrU;wYG>6CoA! z7k6*Ob8C~jOA;pU&Cl(9>}iAD`aw5+?$+c!H}*~f7!i?l+>L>1^+rZ|j##RU{OySh6h!WeOyLljJ5 z7vyb|gXREd47sd~Ybp9Jt*VNDEf0J{DlLPHCfeMxpC)`080h$u-?rrsnp<(A_fdC_RAH%e=is$t;RyR7L8pIF+3J)Oyx%{mP!DjCp zwll{sgj!bKGk`fTS3-UDi8M2YznON%p*})Jgqz_cE!!+z^dtucA#pCn0RWlH$@p~S zZ&S&v9aTNy+eHj;VP)+65U#)x9W)MvVPLXLxf5e);EIbqYzw>(V0YRX({X7E)GgLUcH@WEd$k#(lj4_V*%_v zD|gKGBTk-*H&1F6wl9ht9HPEOSx7GqJ^_A^p1dIcG5k>9HpG}+*>1ZzL7>>Owi32E z@uGS|p(t`8Xj|ULRr%Ae)X#fgzOQ{vuXtGX{H>PxLBeh-ND6l&voJOFj@bHnmV;9z zeWct|@KEx;z)-N{^7nk~r6t+*^N+0pF4#6dZqzf(n?= z+!se746o9TpSiS!hGsQQVp*fL$bNDI7l?>Rc0V7`abHRQ`)HM_|>%3RbY4N8C1zW$Jr z=z&5am*FeIqe0vOjr1zea{KmBqZdTG#83eRbF(Ez9VaB2t3ciia^km_Y4r#a7SXP-9mvW;c2B*`T`!4k%pBNs(e?c6~GcJ@b25+q5~cFOHyy9MY+J1s0TBh-|EWur*}2fIsQhZ;kGoFoO{ zimrDJ(td=^=vyzQTq!fD>sFS8<}i3bCvrBx86rA@-skf{T72t*R-&QDVzy*DiXL>) zV#b|*jO1dC4KM=oWsSVfH7O?-Y!U6a1>KhgvC-^CKvhjl*MMw%b;j@~4`PGn2=R90 zJvkbB+P@+${8#3<;JtVAoOR-eG5+({_wC@D#_(*5hfM{Zk`{OB+idP$=4dpS%ofHBMf{!_M^TZQQ9*2?oh)95)Qu*lCIBKSAs?9Ax88XqTuO~$XJl@+l*ufNp6<)HZb=N|&K)Ccz zM2R2V&9&VOra}#Ss7k8_=~8qDNLI#$C7+CD4cK&3;BY+>u=mbTDv&5g>J2cuR;NAt zzO$hkIiw<@orZnD?OL*$Bgk-)?JoTI8Fv7}z8#X<6<%7{ifaml>{MIAbgq;}(GSwv zVxkW5uCe3^W&`t(5mrJ%Gv*@!%Q7ATCD1;@r1{_ zveve=d08zk5LV&qB~w2cs!7Wu0+}%@Wb7-Bl;{4}=Kb6&3)kzm>Np)JXPy93tutym3R9{h zRcws^qIdg`F^8}oI+B|{cxDA-yzG9m?ZDmccrD+M<}En3p}hK~z4Sz5>h}g@nih>7Q@Z*s7ObxT>;vv)KQ!ll}L0vg)W7@YI;?Me*6!b=^PPTM~tf;Bd~| zh1~3)(XM}QH)v#D@D*W7{`+@&sQ=l%B!`3@>^1u8__$rL{n>6ZnytOQo~H5VcZ>YJ zox_;KNg_MxUtg1pBJ{6I`m2}z8cTo8xc{zoDOLu1(5Mg|84MbjbFHW~R>Yq#Rv(It zY%HXw4gYd>^k93v8+Vd$pt#4lk!)?Y`|Bw*%m;9{zHE6jN&h zKbMn}GamRGn%d>Sul^b1#}CK3j~_Bj@a9W?a*N$LG~%*d9Zc&CFeV=m3ySyUJ7)Y5 z^eoiqK?ngcGnw@z5(HD%P%@QP7DcC3%3^HyD0?1|Gv6~@Dj$@U6eeXQ=}H_h_%rLw zTJ@rNTSP_Xy+^1G%9$a314V4uVy|?UmmBWhQWg)eTb6PYlx-N z!Tkiev-t%D3uxCtcaDYYtbP0<0NyRzoXqcJp(rb_tcv~cv)Xh-;rA(~=K)W^XM`m{ z*u9>@C2o9L7|y!$2)SCN{o&_iSsovRd37+xAt3fCcxxF?4Cb02WKsAu`B}euAQ>5n zfv(mZDqr}1f-wQ@5^YI~tMwkWnL2lMEq>19#8njNH$>((mSLNqt#D+SpT(l8u>{%+ z0qq<@i=izZ4N}tRhR=rLFv&=7wC4?VP|3n0JKg}aUR3BIZpyniV5rX3CqIG9zl=Hc z<>=yvpD?;`tF)SQLdiyJ{%IPu-aQDs>u+dvck*$govyw2uHqd*zk|GZ-B>FtkB`HH z&iYyR>3YM-u@9qqBSe9MYca}GLwuE!I)DvG#zPkT^bM^`&Ua?~=>SK!B1|+KlB1SN z0O~g^aFK9yK%w?ai)_CQn={a+yb=J`M2IW4i?7zYc&@c*yk&~sh#e}w_h_G-MP~bg z5Uv+8X(DS3O=-9rfs+R<`vb!H#!Ge0K+#`>sfyZB+sXR#dfyx?EK?DQWx0sOf|9N& zXJtos0)RMStRjsl42&gZ0gULWq@>aqKWI72W)O6stHbvIpr>=Ouu`u-$*-tJvCE!S zo>?f)H`e;IWG>W4;hF82+c!+R3{AE8gx1jiMyRClD(dQumR5!*iCEmI(HL#;l-rn{ z`#BJjt?l=^Pb#{2jDv1hz)~bm)JT`x`J3&qJ$T(ll4+LY2rmv>lMKf**^jd?*^Y`U z1~-?D7Zw)+B6H>l;6*tdTWd0IU-GA4E+}))O~nOfMl?FE2{_TZK6vwG);YsOl6e`G z-d6>alRuUGLFD|$xWVrHvIs$9()^q#-yMFqzsb(dS*xu_hbqxBFfEwN##&nBD(EAM zk|V%aJ#bV>Ull}BQ0^7BHr&ICsJ6*=z8PNoTKWr}g9R`WhsQ@r0E}4BG8C%|Vm$Y^ zb#-J4gAqO#Y(`P}Tx26f46cIes{5&zwB}~{ne{qK64Sc-iYGLgwN&d;s2zrjvniaE zF`0j-vAuRCa0FIed{ zU-f|vwDkqRbb;G-h?#?Qay+0>Oa3wBoE^aJlCbSQN&7yRzp^nX2z~+ejVd_<%nlHa=&yAvVIx?fV9Jmpr6w zXu0x((u0%zjx=%9iG4p>u@T(|hcB2&PGcgSkHG-#-O%|7${#jBh5!It1Hjmu+a zg{|{2p{A~Cn4Yq_*42;Jh?W__R!^`^>Rz6WX@#q4XKn?6DO2nGX^_tg-AH*M)nnYW z9m{#f{MKm?E^%8MYDEpx&AqROI3siebY+$Ujt5EN;dV)^@*K|Jg#Mfh)(mEhx#$!NB*AWUQUPYj`C*{sdN3+9#cYnBWdy*AyHL4(_nsio)&FY~ z1Lgo5M*}ECX?=KUun%|$*nF+pL>yQ$qHu+FI#7uox{-37m1V!sHF?Y>VQa%+gYi*J zfcX6f&z}PgDk|J+%k!GrW9+%wc!PzU%P7{&zHf#oO;XQ`Xjv>F2KUjDaHZl*#8-Io z6Gl`gFQOgIaVj1zzPi4;`R(=F`%Tu|bcpQO@rHWjyfVXaaZckL{&&2~v@X*s{&%8f26p{Qs`&9zH2F;mNYCs57wY85Uv*v^>r)V}zZ=$z6R+wprR^q{c zkJ!Hyx};%ArAc*!AxW*ibLwh`l1FaWRBz8}x$zP_cO-#kDXLFi;i` z2u*dPFRy$){)aA6xTz9@gBxEF5AW(S3-3Yjzz{F8n!BKWi7&VtzVcXN+c3J8=p*&1 zrGG0X#90O;^Xvep$uCb#*<3$j2)(3WY}`Jtl9h_aQ`YquG+TlB;e@OYw(T~|+qPs` zSJmhK2-X;h-{<|7>?{B56%s88L0FDyTKDLF<8U zCk>66bR!22#~6`h5clR!7xeN`Bcm(*tZbZ)b320s=lR)UE)*0L9&n@{Fzpyzt;s;D#?3T3gJ9vwEVekANv6n3R@^tv5L45q_e%8D1d^jVr zW+%dG52r~Gp$^wSsIzJ4P$SuQJkEC^I8LT`)^2t+;(DfL^?f*5&d;&S<=h*T%@*5O zm_(p5qNy*zW(s6yfEHyQOu@9ketGKO8|@^Q{Y#M<;mB`OpVRU8s6oU035 zA$EU5`^ka)8`||NUvto#ae-@2H~b;{2r>bk<-Rvej2TtM#XR=48*&FE;Hh(jkCxJt zrFuIpXBsZ<1#Qd4;k39L1Z`kxElvxJ5JEE}dv*-WAR!XFqjn;fGy-_F=PVF=8cyIr z#~2`VynG!_$4#!iomt%Gcad%+cF_MEic^I>T<)IZT3DHcZ@^APzwylP6a@zsQ+BT9 zO;e_#-3Ut)LEA)zgXOr(%G0Ru@OC|HV%;D}CE?jAM$vaNw70B_u?kgbJ7oH%k!%m& zI?N~W$3Xs3Vn&Ee-+q?RoX61 zbA-w)&yn9;SHG;nqtshSp4Z`<G~u0-DUTiyi0o+-cP!Z+yqe?>D(zz}Z&M4Y@&zk0CrEJQ3S_z5 zCZT!m&-0zucc&vJT}1niaz?WH@5QehpQ^dARIhx@0K49gf-p?&Kh`Z8EWE63!pm|R z&)w(vD*Ss!*ak0WSdcF^VAS0)mgrD?uf07cCVSLn>8$jHcKD)!qAVB5@mB7fBB*gS z-t}(wz&Vkmin`zRz~LaLNWg7*tJl&MEY1Fv!|BxG+bjlVorAMScPxO%lIc9{2L;Kb zLj(6S0~aO^j*~V%&v$Rl6V#B;6i0`NWSg25X3lz%eJF3&Q?DJ*Um5!NMB+v#j+G_l z&jpdI*+h<*z;()LxEMe@=hZXkM+`Q&oh%2v#6*+V1Bt-OGM<~z+!8=s&}1L!Bc2;S zVD@maTTv3(2Q>SR88#~+Xfjjhf(;Oqh-9^c*ja`Y8ua0d*el$GGGLe=5ssUEP(Y)V z>J@vIq45-8L9DK;aa73;7KLm>Z3%d$QWNp)Ty@O)9}W)=9ms_V>(7{zS_9d0XSC<_ zR5^dHTpe%dnGH9<>;}x)99cx$u?I|nJl!eC%0QZ|tY^tlqoa9DKd95|1uv;RvF%4q zQzWxv+Ix_u{F9~0=p}&Wmel7|GSJdOmuwov2oct7ogL#Eq$woi8NrlkL%pJERL0fW zYvq;09$~pLnaBw8R$&YNRb$Wj&!QdYTGHU{#XP^;x4^4wyd)K#&NbaCO8kekZV;(h0H3JO_vkLc!V=%X(RD3Y` zF!v#a67`y?X@uxIzc-hFk&0sn4{T*kl2suHi!(N^YNK5W4YZJpoau9=XimOaU6&zE zTq>nrN-3mIqK7 zKIH;H`^GWG`~Y?rQVfwCSUyfu`K2ykJcoKDxdxKWadXSQOYAEvU0YDR&;z`u_SVQd zmFZthxBrpY_XG;A??n1Te+M43}C6>XzaP3WDvE%A% z0S-TNx3XLBXCEZ`Q2rhwF6TuTz9Ij7EH!Pi&nEKap&#>kcXJTp3I+cE>wv%d;ji)V z*Sz?1E%`rVeTaYV;7o+JbpgGjIB_%K$@&*46sM*^fm$sElY4%r%)S5L(10%qbXm*) zdD}z3{RmaKqK$+%w4Avtg?^HE4F%K=3VeIUo>jBabU_dO_BHHGa4R4|Prg7u#Jbt* zBCig=HU!&LFloNKhC-Nv(KJe*p)}9Lzbu;S4W{ts=+g5gvlV+utZBUbD2jDHWx?ub za&cvgttGyY{lOzNFjJ7euBM{a5&L%9(^co$@&0AJn3&MICn1a`WJb&!iWR{Q*vr#?_+Kuv!WV3LFb8Or@TkXk{~$YUBfUI?*iD z@+r#&+VdcEQ&-}sm+Vod5liCizG6+1uXpIV7W;;g#PjcQSqpy#>L{$N-;32G!ZVcW z-Qg5q>#g`MGS_(A!79_Nc*5#(SFO__ygW{2v?EdT+82IOO00$H$(rNexeqLKKYPPR zp@-d#`?~?hcHjOTLOkwEVo2cH$SWJ`50A!@N^SEW@%$4Cr%3BL33{r}En9I}PF}omNAAtGk3& z=9W1sI;bp06o7eGT8Y*>p}hS6jkuurKa#Z#V<@luzuNoGpthE6O#vp@MUqN&qc$lkMGmnBE z(A5M}6%@Xz3_iv1qQnRXTH=M1)V1@WYCN8|nE4%V$IjT%Ju$USpZ_wqzX2Q5|EeQ^ zNGDioObqi_#i-@S8BymAz7p$;QH$7-*zTzgeB~g#L5K9nJS1LZk(Ir{xeSl3{@tLp zH2_5s<14~R4W*aW)&odLST zc9&B1=d8ykL*9r}Q+v<4cI{S`$d4r%pFMxk{@N+NDI)owEVAhT7%XM}D_HvPUW1u@ z<=`K|(#L-XOPy_)>P!(#wZ`OZ@b{f~8BqX(szl&CffKFY?6P@C-(Hnofs@-V4@~PfAd>}hjvQvOC)h3mq9VNA|j1V~xnomr?!3IG6Z@9K!gT|=09q+NV5 ziYhMc3Bxk~W?hZcc?Py!vYI5W?ePgyKz~zwtfNk%u@PvYDODquq@5a>i?~}w8s5`Q zTTJ7{!@#jbtMrnoGKbcUv4y%Enf+eN07x09MlFiAq>QFf(%CUv(3FmzYNL6bp4|UQ z#PFfN;ozs9aRh2HUto(`NBRCLOh1Fg!F^h#$klW~!L$h<;;S3D%+6)~^lNksj~C|l zw!6Q)%ZZY1Nnx-f+pE(-z0TMD1^K4z93XEpvSHL=YJcr|yHMwlMH1URbmmqWa&bh?*-yn z<+oNR=SocW;qbf6ot@2F>#iP7z)zkuvYw|w?c_DHorAwD&?oFB_s8bc;aI5jVH(cYcKJ%74tFCHYI9dBay-QVV;#w`lLxF_>+fX_AwOod!M;u9h2SXLhm3i+^Va$(qb!WPw(i+q*TkHP$D6BQ+iuDn*fo^uLLwu}A!y^7H#m3#GS z0cV%~ebDlgWooingTQoFl%kYYg6wf>D7Oav=YA~S8K!!S7YfxfLocAB zfgGUAL0x@mf7{9FAxmg0u0^m;^x{qxlD?|kN7~nW3itueFJHao<8=?d$TBhXK<4X= zbMU7jsD|sr%5uqSrlglh%^#!gM(qH=yotxiOm!RIJP3vgjkmGC7gWn zO-g=d({l!OO9~Rf&d8LSaSf|-36B5ujh2Rw{De5j;&K|{UkT#zcr}P`$ zYol_ENW!|<=z;u+gOVGf{+P(wt$v-@3dumk^MXOws9HUmb$Isp(y_uTp(SwYr8=_F z5wL6yq$w3Cx9LUn3VFg^v(ZXluz6*HTWBb+`!6~m_oFCrf&xj(#7%0CyE>Kz-el90pB8QU|4pZzn6wbml(jYTn*dlYjio|;k_8;hnMQ9wOq!&NCF1{QI znKso^GhV#`7O2M(I=)J8MM*VvH+6?ofL#@gMuYTM)E~4wecjf2zTU~fE%w4u?=L`+0l+*D0Kf+RV=t^n>*efppF7eNEC%wjJ-Bnp#ywBU# z?W*fv%sm#$BEr~1!f}G`TNJl&Qj9i-{*@JeQ4EFUbg&7QcanbWVESQ+O?7v)0g~r? zt`;e(9Mfkp(tKLV%njaf(vC*twBJMigT z?&;$C+$c?*wz`k3duWaoH%Wf%OV_W_Xq)HFHD0W|h}>!RyWeV@N2jN^Ec{ZSYAl^? zyUkHde+X`xc*uSqUmITiku!EC0Y^(Z%8nsliJQ(3$Y(XkjoOJF_{;5yh(MN#g=&vM z45LA18-zZRK{nTx6y^Bj6G_5EgDVMlI@aHzy_*MXQS?FJvL$!xqpigB4$Z)Q2W)A4 zr!bpd{)ZZcmBcr4Vy;%jF1Wont|?$*Zwv>Bx8QWOp%)V6U6vhJ^mG1kma$znr!wr0cuh0FLx!neg1D zK=~vlpknNp39JLBr31BmhakeIhGZ?c6BSG;=I~{2om>00I9iPq&TnG7H_hFJcbN`ik!MDcKgJSqoEP6!mK=+MW`9(7AqAg~3h|%I!bi-a@XK z+oarg<|#qH6}K1bJS`nDUlRYidFZ1NuAWyfrITKLeiU4I0$!+HG+vmUU+n$x$#~xO z^97WB@~K4UOokM>+zXu#r_H{;MVtXZF}f7;WV>(1Z6eSfr4@}i9+Z|y9=zb^BOpT{dZ-peC}Z>HXJ zlXkp$Qa|Wy(SnsZQq7&iAV2U+r%O$<*g-Pp!u4Y zSCF^nyApT8p8&EVv!D6ggMR`d3>|}-?r3;+WsHV@Azvx`>w}@N;5T*2Aor!;Et3wI z{te#rztgMzS474Clhv5&!w;G@kJk&h)rjAYJR%@-`}|rPzeL!BcvJt7Mc%7|{UuH4 zi~eKxiDs({0VT3`t)XN9&YxsFtSq?{RTpQF4&-K8L80&uf7v; zdRte596+l^ULrgZ?CmomU?-%YQ|3|G}ptaj=RpMuMp|88e# zxzILw#Exjl#UdRzOH>xRg#NazisMg>F6;VFR>Sr8==Fk&8>3cR1|$+^xn18fzliCF zJrg{+`xXC5_iu$guNTwOsG@er>tNX53JZ!{4e9g0?3w;nm_Gd1ME|-+|2jwidPM%O zxDgiUe{ny_4x?U7i~ree3EsG^`oG=W$HO76n&k57UXX!boVa(c1~?xk71>WT4R}XU ztik*_I$BOR@^foF4a0D>oW`gHIR&A9XP5#9<>9pu35~I@C+1K2|Dg-Jd8wdD(cW!s zrRg009xBMAp|bt*YbNI9PoY+)pJiN!e*zvq31j+>{bSYk^pDSF|5QG_1v_Q`6W|g> zxS>VL`8RDT1-(IQ(yuvM^q+uI^uM`Bsfv-rBK3tVKo35JUdXp>X-ONHnO_T6gRbV zzLl8-DaQ&q(s#+6fxhmIOQcYOW~pOXyE5JY>B6nQgSu;VWL#Ye!eiOD`^GUdCX}JW>=#k!Ql3_Yeumq%E!51v_XXjvUi$XdOW|C{A^ek5g#4a_;P^D-5c~j(b$y^c| zn?q{%%JcgV(km{WY#YsXVPDndCV#n`YM*FD?g0XcTqRM4?Aw~{GBYRDV6vDDs`KXN z6~xp+HgW_?E=ZbMbn0YB*FxTKKnVf9F$)|*Suz7&d zV%`#SaWB%)@{#;32O8)6+;CdhTWmJox7=XhPzyhG5ggVs*$$t#yrnmPo zv1;SvJ4&Dv&01StR^!bZIAQ0+h;^2}g*R=F74$Wkm+cfOjK&}@le=UTs*5aS!NkyHxV`(B-O5EUyJ6>pHmD4-97OpCn zzT}=fZc{MTza|+!@u>+QkrgjJ^F)fCdp8pGRo>&x63Ck$Otc)%cR6(bQkX zJ`GE2NocPsB>bV>gLcZ(8$DjeJurMW<{uxETVJ)P_ks(3y~vSjvwqgpvFnY3TqYo3 zb`!+TLOV)jK`jbA@4IxM&>DYEGSg7fMvnDk{D6^}Oo7n(Hb(~0b8BvjI z=0*S7VHcE~Tw#fY1zA%n_t7%IF0rkx2o{-nv^Pab7D&0_G&rsQ-2Jd2G0OLQk8u*P z-wr3wZs;_`uY{Ke78kf{C($mrw3L^P?{C?pVx-hB>a z=dK(#-a|i4i6u8NS%9iTIU)Q!m5B;wqjs;!Ob3@7F>#^oUxMIexRr!#Cs z1icNv)gj4h>WNd%vi{r12%l2ogl{WTc zp^9SEJnZqeRAUXzD;;43b({*GKe_QmU!hlP(|~g`A~4=~`n=uU%el7WdksEsmg?&p z&9kWT#JD>_S>>_$^3Sr;K4liMkO)I_zXt_QdM)8yI7`Bw`-XshywAUyXqi#jG!6ts7d&8h_jPpARP!_PZP(Iwr!~;` z!{Km3@^B;VyX2`+x8d8ko@%s@s0@uLGPHqEVs2dC|LSPVUTgv<<53@9uolz<%HxCp zf)qr|`WF@?O-78bUyg!Zkb54|Q=|lIZ@CQ0^*n1dX1$yrqb{~I7PH&^l6yE zRnu0bGd@h*CQpv;3Slac^_IP@horEhn57F(adm81dZVw21XPir7&yckC%ypR>nKcJ)J5V1r}bBF+l;!o_C@l0v; zwsH_9N0}k^@)9Z^9;J>w-&08jcR62x4!XbCt*ijpPAYT=)N#sJdmCUI2eKPxqO73-WIUbeP*u-oS*sy3U zJv+MI&zK=Tdb{q>VWP6m=o+ucoyH+EXS<9h2yCCeu0q^oG0&-JXCpQ;q_nt?b3Q8R z7n`=6TrrcXR|v)%8@s=XjaD;cR!i}p5R6j^h{sMe?D;QvEV#EVe^GkalHtDo&C=YF zbqY*>dYKFHBY9y?yMXlQUq9{wS68M~-!GONc%+w5H#~{V=5t1hXc3cWcK8^LI&aG{ zdff@KcxFE8H-20TzZyZ_qHDIwaaEgeBOR!zA^n4PW!N>#(q;ecqYqL`un9?BO@ZdZ zs^lpov{kzP9c4AdHiQg*^^0FDrY0}JUN1d)2qc2wV`u<};hh1*3Q-IO43Dv(r(G1g z_RJ6BwdlekLvMmS)*o+(zc$e|LvwD7DHm4mw?U1$&3DS%!s_%dN3ZG$Nr(u`-t{3f z51x5A^_jkrncF$aWrLY-W5g*t+i&>YP8ox-=J*nUIQ`78;Np4sO19{n!<^&5`Ul>} za+y~fN@cE5{*s#fqE zYeyR_9Z%Zb+I($3<4GjLUhQjY-DvWlx~3{WKi@UBYjGqk62OVB6-ZNb6iZh|Dk5a}LmUhp4kC6>DwFlRJ-Q z#7=H##l)_k^a;LY7LGUUw`MC6W7v6wZ`ORKNRka5<86(PB8z(~OBb#F9FgDv0Yo;2 z=cRU!u=DAfF!ju6gBBhCkgrC^i9#D?*fm9R%q%WqMI3S2u_s#M&j&Q}xofPPlz~eP@GXe1`yHp#-w5Yn zBAT=A8(c^w7f)0`L`7>S9$&q7>6X1Rssyz=cIA(PJjqKUUGVC^W*Z#U*Zn|L#UsaX zlN)@AY#%<2ai|O3{bCLjU_wO?U$YDIdj|yYpM?7fnvX`h{xx8HFGPC;PqW=`a_8 zrmCfzy3_U<6hW^q#&;buhiq=H?kY-j9Stb+1!rwdDXWPebt__W-dn8HWN%UK6BVB^ ztZ|HMVqG6+`>6<7*>PH!exy}vD1(53^qv~M3w_d}a}82g!2!SDwtQES>&&qlV==aa zS=9}VkvOwMg7mUKTvy3bAJT0JZ7rinT({+{%Nw8w?yi@J}|R;B+l!DV}?tJ^+oj{7GmSqJ9tbZznCbl*E*gQS}W z&T!wKrdEO%fi@!sw|dR<`Nt_OPtg=gAi0k`5#4VS`ab_N3N9_w}z{C>ecJm)z zfNk;4GV)CmxIKlPQ!fB3aCb!~$2>O3ebQ$B3OBm#B}-fII$yV>b`U6cxUjL((ErQ} zNM^lVYb)==I{P~(cERmdlV^|Wv*;f1hjB#8R0!O#MSSAPSPEQ{jA)Wi&pQyWDGyU2 zGI*|{;H5{Beb8CA`cS=8p}izTvT;iUg3MU~8b(W)A4ESI*$u0W3B0L+%r)rX@8393 zLx)H!K;Zn)4!7Ug)$|(Ja<+@hBPUVy>e=x_1fJF1P;IBI*dNIb68C?U4)v178iz<3*zPenON@r@*-taJ?ef7pTBmCWMn_DJJ7TU7G-+~c8PA<5RaQeajs&F_gW ze$|9=^#&Rh`wyliJqDCew&BV=9he?+6G8b3DwqKBf}L zu`w?&6*9Hp9tG=m{(Yy@D9%Q#Jh)(_qV9=Z!bGC2kpN=PZDiOk`d6cO;{hwHldNR7 zN2)~-qf&uU+vAneUXi0~d`yb?L_?kte8(Q~<=3ZfN+1`tJWXAVPtI4>pZUE)mCwL+ zV<32IUAjq`AN3r602(SQ;=-Q*Zq(+2qSm-vbe?a^VI>XJ$k<1mQXZGz3Aq2V1Eq(w z=DO60PMJt!>!#_fCd;@kSPgj@eh2dF)<{`wIYua$j)jnz0^bT9%%B^pC7N(|Yz&=V zQ{bS&-1-NysrhR?2t=~-vR6RGqOSmDisssxCX$eYzq!F15>{nRK|dYzlS#~=smza! zV^04xI9{anxYs@q37`%UR5=GEaebjhHhGgZs76@;+)&7 zD2Y_^8y=|Aid?d~)MSt-J$6hoA`^`4i($+YnWYR^PHSz*-UQa0hiLM_bR=Cl*sZ7> z!e-BQqvs}shiM`SF~cGf@crh{C=Lv|;p%sK?DV{mx}ky8=|*N*aEG~kzl=7Uwydpa zD=RwGEpkZeJ4~I~PhKp%fa7>+VdyRrEdTopiZRX!(uwpFQN?bendirqCn~a0yKD}U zE=O;*44LYcVCn`;o?^;p-~+d~yfoDf)o;(=`^p<#_*U|vfxukzM+xH+9So$y^EBk@ zg>_9y#7yaLQMtZKVVA^R;0@eRJ92evO^L8XBdZW*Ym1~rRtS7s)`9=lIeHq^+R9by zOL_lPk-XlxbrNV{5<3Alo)ep!gBnmC^J5+74QtzdW+lT{3)YDBtM8UzBUU~c@TZ;h zf6VBiQ8rM6m@;b_ug1%D11b`Vbbxm;*F8IvE-Z*WYC`Rsm#AvKs3(01YH`i@X){9# z4k2|kt4t9)3Yw5j5#1u4huhlst*UNtS+R(oF?B_x42v2Cz};%;`~3{KK#aRZ1c=@`WHk=?~E+A&H5Kc@3w6{NCB z>tC#8H1NJ87Woz5BI_4r=jp1PQqg~8mwz8;dxH1)^`oT9*{h0aKa1A}os)L3Ns^|R z?Mr%d9B#0-S}1ew_*$FydB$e5C0K)X8O9`!rO}d|1di2k6xvZ_RF$T+;aQ8+6?Vt~ zJKqNs=El~?-wBq;W?`52%57JaAXHINvjgh9ym;uddJZcH^*q~8dju_Fc0T(UE%e;r z0^$=E7*p8U?#1C}etkBcArF3aZk1$vfpB73e`08B$o|YqF&c~fsm9(S*RBd+CDbix z%s#_|z@L?O&l`eG^M#w(7KOvNuk`OmPpNRK{cb>+nDG6Z9ya{4cG|*mSYr0!T%jl$ R|4xQ`xFC$4?vFoL{s;U9hDiVb literal 0 HcmV?d00001 diff --git a/docs/design/mssql-export/preview-progress-light.png b/docs/design/mssql-export/preview-progress-light.png new file mode 100644 index 0000000000000000000000000000000000000000..027f32ee3e6f78bc96f33a582a838f42bea5c71c GIT binary patch literal 97746 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/design/mssql-export/serve-preview.py b/docs/design/mssql-export/serve-preview.py new file mode 100644 index 0000000..c7d4fe8 --- /dev/null +++ b/docs/design/mssql-export/serve-preview.py @@ -0,0 +1,59 @@ +"""Local-only, allowlisted server for the MSSQL export UI prototype. + +No database access, file uploads, or directory listing. Stop with Ctrl+C. +""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit +import argparse + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +ROUTES = { + "/": (HERE / "index.html", "text/html; charset=utf-8"), + "/docs/design/mssql-export/index.html": (HERE / "index.html", "text/html; charset=utf-8"), +} +for name, mime in ( + ("punky_report_loading_atlas.png", "image/png"), + ("punky_report_loading.png", "image/png"), + ("punky_report_loading.wav", "audio/wav"), +): + ROUTES["/mySQLPunk/image/" + name] = (ROOT / "mySQLPunk" / "image" / name, mime) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + path = urlsplit(self.path).path + if path == "/": + self.send_response(302) + self.send_header("Location", "/docs/design/mssql-export/index.html") + self.end_headers() + return + resource = ROUTES.get(path) + if resource is None or not resource[0].is_file(): + self.send_error(404) + return + file, mime = resource + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(file.stat().st_size)) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + try: + with file.open("rb") as stream: + while chunk := stream.read(65536): + self.wfile.write(chunk) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *_): + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=17643) + args = parser.parse_args() + print(f"Preview: http://127.0.0.1:{args.port}/docs/design/mssql-export/index.html", flush=True) + ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever() From 87a3498451945b61aacc680508b17f2d2c74bab0 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 13:55:49 +0800 Subject: [PATCH 02/32] feat(mssql-export): own export session and immutable requests --- mySQLPunk/Properties/AssemblyInfo.cs | 1 + mySQLPunk/lib/ConnectionOpenService.cs | 30 ++++++- .../lib/MssqlExport/MssqlExportModels.cs | 87 +++++++++++++++++++ .../lib/MssqlExport/MssqlOwnedSession.cs | 27 ++++++ mySQLPunk/mySQLPunk.csproj | 2 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.cs | 82 +++++++++++++++++ tests/SmokeTests.cs | 2 + 8 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportModels.cs create mode 100644 mySQLPunk/lib/MssqlExport/MssqlOwnedSession.cs create mode 100644 tests/SmokeTests.MssqlExport.cs diff --git a/mySQLPunk/Properties/AssemblyInfo.cs b/mySQLPunk/Properties/AssemblyInfo.cs index cb14305..63985c6 100644 --- a/mySQLPunk/Properties/AssemblyInfo.cs +++ b/mySQLPunk/Properties/AssemblyInfo.cs @@ -5,6 +5,7 @@ // 組件的一般資訊是由下列的屬性集控制。 // 變更這些屬性的值即可修改組件的相關 // 資訊。 +[assembly: InternalsVisibleTo("mySQLPunk.SmokeTests")] [assembly: AssemblyTitle("mySQLPunk")] [assembly: AssemblyDescription("免費開源的多資料庫管理工具:MySQL / MariaDB、PostgreSQL、SQL Server、Oracle、SQLite")] [assembly: AssemblyConfiguration("")] diff --git a/mySQLPunk/lib/ConnectionOpenService.cs b/mySQLPunk/lib/ConnectionOpenService.cs index 0ffdf60..fedfba0 100644 --- a/mySQLPunk/lib/ConnectionOpenService.cs +++ b/mySQLPunk/lib/ConnectionOpenService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using MySqlConnector; +using System.Data.SqlClient; +using mySQLPunk.lib.MssqlExport; namespace mySQLPunk.lib { @@ -11,8 +13,16 @@ public sealed class ConnectionOpenResult public List Databases { get; private set; } public string ConnectionString { get; private set; } + internal SqlConnection NativeMssqlConnection { get; private set; } + public ConnectionOpenResult(IDatabase database, List databases, string connectionString = null) + : this(database, databases, connectionString, null) + { + } + + internal ConnectionOpenResult(IDatabase database, List databases, string connectionString, SqlConnection nativeMssqlConnection) { + NativeMssqlConnection = nativeMssqlConnection; Database = database; Databases = databases ?? new List(); ConnectionString = connectionString ?? string.Empty; @@ -68,7 +78,7 @@ public static ConnectionOpenResult Open(Dictionary connection, b database.SetConn(connectionString); database.Open(); List databases = loadDatabases ? database.GetDatabases() : new List(); - return new ConnectionOpenResult(database, databases, connectionString); + return new ConnectionOpenResult(database, databases, connectionString, (rawDatabase as my_mssql)?.MCT); } catch { @@ -78,6 +88,24 @@ public static ConnectionOpenResult Open(Dictionary connection, b } } + internal static MssqlOwnedSession OpenMssqlSession(Dictionary connection) + { + if (connection == null) throw new ArgumentNullException(nameof(connection)); + string provider = ConnectionConfigurationService.NormalizeProvider(ConnectionConfigurationService.GetValue(connection, "db_kind")); + if (provider != "mssql") throw new ArgumentException("MSSQL export requires a SQL Server connection", nameof(connection)); + ConnectionOpenResult result = Open(connection, false); + try + { + if (result.NativeMssqlConnection == null) throw new InvalidOperationException("SQL Server provider connection is unavailable"); + return new MssqlOwnedSession(result.Database, result.NativeMssqlConnection); + } + catch + { + try { result.Database.Dispose(); } catch { } + throw; + } + } + private static void ValidateSecurityCombination(Dictionary connection) { if (!ConnectionSecuritySettingsService.IsTrue(connection, "ssh_enabled")) return; diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs new file mode 100644 index 0000000..0a781f8 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace mySQLPunk.lib.MssqlExport +{ + internal enum MssqlExportMode { SchemaAndData, SchemaOnly, DataOnly } + + internal sealed class MssqlObjectKey + { + public int ObjectId { get; } + public string Schema { get; } + public string Name { get; } + public MssqlObjectKey(int objectId, string schema, string name) + { + if (objectId <= 0) throw new ArgumentOutOfRangeException(nameof(objectId)); + if (string.IsNullOrWhiteSpace(schema)) throw new ArgumentException("Schema is required", nameof(schema)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name is required", nameof(name)); + ObjectId = objectId; Schema = schema; Name = name; + } + } + + internal sealed class MssqlMirrorOptions + { + public string Directory { get; } + public int RetainCount { get; } + public MssqlMirrorOptions(string directory, int retainCount) + { + if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Mirror directory is required", nameof(directory)); + if (retainCount < 1) throw new ArgumentOutOfRangeException(nameof(retainCount)); + Directory = Path.GetFullPath(directory); RetainCount = retainCount; + } + } + + // Captured destination facts; capture/revalidation belongs to the publication task. + internal sealed class ExportDestinationStamp + { + public bool Exists { get; } + public string FullPath { get; } + public string FileIdentity { get; } + public long Length { get; } + public DateTime LastWriteTimeUtc { get; } + public string Sha256 { get; } + public ExportDestinationStamp(bool exists, string fullPath, string fileIdentity, long length, DateTime lastWriteTimeUtc, string sha256) + { + if (string.IsNullOrWhiteSpace(fullPath)) throw new ArgumentException("Destination path is required", nameof(fullPath)); + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); + Exists = exists; FullPath = Path.GetFullPath(fullPath); FileIdentity = fileIdentity; + Length = length; LastWriteTimeUtc = lastWriteTimeUtc; Sha256 = sha256; + } + } + + internal sealed class MssqlExportRequest + { + public Guid JobId { get; } + public string DatabaseName { get; } + public IReadOnlyList Tables { get; } + public MssqlExportMode Mode { get; } + public bool IncludeViews { get; } + public string TargetPath { get; } + public ExportDestinationStamp Destination { get; } + public MssqlMirrorOptions Mirror { get; } + public MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList tables, MssqlExportMode mode, bool includeViews, string targetPath, ExportDestinationStamp destination, MssqlMirrorOptions mirror) + { + if (jobId == Guid.Empty) throw new ArgumentException("Job identity is required", nameof(jobId)); + if (string.IsNullOrWhiteSpace(databaseName)) throw new ArgumentException("Database is required", nameof(databaseName)); + if (tables == null || tables.Count == 0) throw new ArgumentException("Select at least one table", nameof(tables)); + if (!Enum.IsDefined(typeof(MssqlExportMode), mode)) throw new ArgumentException("Unknown export mode", nameof(mode)); + if (mode == MssqlExportMode.DataOnly && includeViews) throw new ArgumentException("Data-only export cannot include views", nameof(includeViews)); + if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("Output path is required", nameof(targetPath)); + var selection = new List(tables); + if (selection.Contains(null)) throw new ArgumentException("Table selection contains null", nameof(tables)); + JobId = jobId; DatabaseName = databaseName; Tables = selection.AsReadOnly(); + Mode = mode; IncludeViews = includeViews; TargetPath = Path.GetFullPath(targetPath); + Destination = destination; Mirror = mirror; + } + } + + internal static class MssqlExportLimits + { + public const int SmallRowBytes = 262144; + public const int StatementBytes = 262144; + public const int BatchBytes = 4194304; + public const int BinaryChunkBytes = 65536; + public const int TextChunkCodeUnits = 16384; + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlOwnedSession.cs b/mySQLPunk/lib/MssqlExport/MssqlOwnedSession.cs new file mode 100644 index 0000000..8fc77ca --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlOwnedSession.cs @@ -0,0 +1,27 @@ +using System; +using System.Data.SqlClient; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + // The provider owner also owns any SSH lease; never dispose its connection separately. + internal sealed class MssqlOwnedSession : IDisposable + { + private IDatabase owner; + internal SqlConnection Connection { get; private set; } + + internal MssqlOwnedSession(IDatabase owner, SqlConnection connection) + { + if (owner == null) throw new ArgumentNullException(nameof(owner)); + if (connection == null) throw new ArgumentNullException(nameof(connection)); + this.owner = owner; + Connection = connection; + } + + public void Dispose() + { + IDatabase owned = Interlocked.Exchange(ref owner, null); + if (owned != null) owned.Dispose(); + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index d6cea87..29e7f95 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -359,6 +359,8 @@ + + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index c58deaa..b112356 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.cs b/tests/SmokeTests.MssqlExport.cs new file mode 100644 index 0000000..c399c68 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.IO; +using mySQLPunk.lib; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static void Session_DisposesOwnerOnce() + { + var owner = new MssqlSessionOwner(); + using (var connection = new SqlConnection()) + { + bool connectionDisposed = false; + connection.Disposed += (sender, args) => connectionDisposed = true; + var result = new ConnectionOpenResult(owner, null, "test", connection); + Assert(ReferenceEquals(owner, result.Database) && ReferenceEquals(connection, result.NativeMssqlConnection), "Open result must retain the same owner and native connection"); + var session = new MssqlOwnedSession(owner, connection); + Assert(ReferenceEquals(connection, session.Connection), "Must retain original provider connection"); + session.Dispose(); session.Dispose(); + Assert(owner.DisposeCount == 1, "SQL provider and SSH lease have one owner"); + Assert(!connectionDisposed, "Session must not independently dispose the owner connection"); + } + AssertThrows(() => new MssqlOwnedSession(null, new SqlConnection()), "Null owner rejected"); + AssertThrows(() => new MssqlOwnedSession(owner, null), "Null connection rejected"); + AssertThrows(() => ConnectionOpenService.OpenMssqlSession(new Dictionary { { "db_kind", "sqlite" } }), "Other provider rejected before connection"); + } + + private static void Request_CopiesSelectionAndRejectsInvalidMode() + { + var tables = new List { new MssqlObjectKey(7, "dbo", "Orders") }; + string path = Path.Combine(Path.GetTempPath(), "mssql-export.sql"); + var request = new MssqlExportRequest(Guid.NewGuid(), "shop", tables, MssqlExportMode.SchemaAndData, true, path, null, null); + tables.Clear(); + Assert(request.Tables.Count == 1, "Request must not retain mutable selection"); + AssertThrows(() => ((IList)request.Tables).Clear(), "Selection is read-only"); + AssertThrows(() => new MssqlExportRequest(Guid.NewGuid(), "shop", request.Tables, (MssqlExportMode)99, false, path, null, null), "Unknown mode rejected"); + AssertThrows(() => new MssqlExportRequest(Guid.NewGuid(), "shop", request.Tables, MssqlExportMode.DataOnly, true, path, null, null), "Data-only cannot include views"); + AssertThrows(() => new MssqlExportRequest(Guid.NewGuid(), "shop", tables, MssqlExportMode.SchemaOnly, false, path, null, null), "Empty selection rejected"); + AssertThrows(() => new MssqlExportRequest(Guid.NewGuid(), "shop", request.Tables, MssqlExportMode.SchemaOnly, false, " ", null, null), "Empty path rejected"); + Assert(MssqlExportLimits.SmallRowBytes == 262144, "Row buffer budget"); + Assert(MssqlExportLimits.StatementBytes == 262144 && MssqlExportLimits.BatchBytes == 4194304 && MssqlExportLimits.BinaryChunkBytes == 65536 && MssqlExportLimits.TextChunkCodeUnits == 16384, "Bounded streaming budgets"); + } + private sealed class MssqlSessionOwner : IDatabase + { + public int DisposeCount; + public ConnectionState State => ConnectionState.Closed; + public string ProviderName => "mssql"; + public void Dispose() { DisposeCount++; } + public void SetConn(string connectionString) { throw new NotSupportedException(); } + public void Open() { throw new NotSupportedException(); } + public void Close() { throw new NotSupportedException(); } + public DataTable SelectSQL(string sql, Dictionary parameters = null) { throw new NotSupportedException(); } + public Dictionary ExecSQL(string sql, Dictionary parameters = null) { throw new NotSupportedException(); } + public System.Threading.Tasks.Task SelectSQLAsync(string sql, Dictionary parameters = null) { throw new NotSupportedException(); } + public System.Threading.Tasks.Task> ExecSQLAsync(string sql, Dictionary parameters = null) { throw new NotSupportedException(); } + public List GetDatabases() { throw new NotSupportedException(); } + public List GetTables(string databaseName) { throw new NotSupportedException(); } + public List GetViews(string databaseName) { throw new NotSupportedException(); } + public DataTable GetColumns(string databaseName, string tableName) { throw new NotSupportedException(); } + public DataTable GetIndexes(string databaseName, string tableName) { throw new NotSupportedException(); } + public DataTable GetTableStatus(string databaseName) { throw new NotSupportedException(); } + public Dictionary GetDatabaseInfo(string databaseName) { throw new NotSupportedException(); } + public string GetTableCreateStatement(string databaseName, string tableName) { throw new NotSupportedException(); } + public bool TableExists(string databaseName, string tableName) { throw new NotSupportedException(); } + public bool ViewExists(string databaseName, string viewName) { throw new NotSupportedException(); } + public void RenameTable(string databaseName, string oldTableName, string newTableName) { throw new NotSupportedException(); } + public void RenameView(string databaseName, string oldViewName, string newViewName) { throw new NotSupportedException(); } + public long CountRows(string databaseName, string tableName) { throw new NotSupportedException(); } + public DataTable GetCopyColumns(string databaseName, string tableName) { throw new NotSupportedException(); } + public DataTable GetCopyIndexes(string databaseName, string tableName) { throw new NotSupportedException(); } + public void CreateTableForCopy(string databaseName, string tableName, DataTable sourceColumns, string sourceProvider) { throw new NotSupportedException(); } + public void DropTableForCopy(string databaseName, string tableName) { throw new NotSupportedException(); } + public void CreateIndexesForCopy(string databaseName, string tableName, DataTable sourceIndexes, string sourceProvider) { throw new NotSupportedException(); } + public DataTable SelectTablePage(string databaseName, string tableName, long offset, int limit) { throw new NotSupportedException(); } + public void InsertTableBatch(string databaseName, string tableName, DataTable rows) { throw new NotSupportedException(); } + public string GetViewCreateStatement(string databaseName, string viewName) { throw new NotSupportedException(); } + public void CreateViewFromStatement(string databaseName, string viewName, string sourceViewSql) { throw new NotSupportedException(); } + } +} diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 501b0e6..1494ede 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -28,6 +28,8 @@ public static partial class SmokeTests public static int Main() { int passed = 0; + Run("MSSQL export session owner", Session_DisposesOwnerOnce, ref passed); + Run("MSSQL export session request", Request_CopiesSelectionAndRejectsInvalidMode, ref passed); Run("Geometry WKB 轉 WKT", TestGeometryWktConverter, ref passed); Run("View SQL 跨 provider 轉換", TestViewSqlConversion, ref passed); Run("View SQL 進階轉換案例", TestAdvancedViewSqlConversion, ref passed); From dd675ddd87856c92c1d2150b64cd9fb60c57b69a Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 13:59:15 +0800 Subject: [PATCH 03/32] docs(mssql-export): record task 1 verification --- docs/design/mssql-export/implementation-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index 3e64349..14cf0d4 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -128,17 +128,17 @@ internal SqlConnection Connection { get; } public void Dispose(); ``` -- [ ] **1. 寫失敗測試,group `session`。** `Session_DisposesOwnerOnce` 以 fake IDatabase 計次;`Request_CopiesSelectionAndRejectsInvalidMode` 修改輸入 List 後 request 不變;測試 core assertions: +- [x] **1. 寫失敗測試,group `session`。** `Session_DisposesOwnerOnce` 以 fake IDatabase 計次;`Request_CopiesSelectionAndRejectsInvalidMode` 修改輸入 List 後 request 不變;測試 core assertions: ```csharp session.Dispose(); session.Dispose(); Assert(owner.DisposeCount == 1, "SQL provider and SSH lease have one owner"); Assert(request.Tables.Count == 1, "Request must not retain mutable selection"); Assert(MssqlExportLimits.SmallRowBytes == 262144, "Row buffer budget"); ``` -- [ ] **2. 跑 session group,確認紅燈。** 編譯/註冊 wiring 與 friend assembly 併入這個可測 deliverable,不另做空 scaffolding task。 -- [ ] **3. 實作上述介面與 Models。** `ConnectionOpenResult` 加 internal native SqlConnection property/constructor,在既有 raw provider 建立點傳入;public constructor 保留。factory 使用 `Open(settings,false)`,驗證 MSSQL;失敗 dispose owner。session 不獨立 Dispose 同一 SqlConnection,command/transaction 留來源層管理。不得使用 ConnectionString 另開第二條連線。 -- [ ] **4. 跑 session group 為綠,確認既有 connection tests 仍過。** 只用 fake owner/未開啟連線完成生命週期單元測試;真 SSH 另列 Task 10。 -- [ ] **5. 檢查 diff 與提交。** 僅 stage 本 task 明確檔案;建議訊息 `feat(mssql-export): own export session and immutable requests`。不 push。 +- [x] **2. 跑 session group,確認紅燈。** 編譯/註冊 wiring 與 friend assembly 併入這個可測 deliverable,不另做空 scaffolding task。 +- [x] **3. 實作上述介面與 Models。** `ConnectionOpenResult` 加 internal native SqlConnection property/constructor,在既有 raw provider 建立點傳入;public constructor 保留。factory 使用 `Open(settings,false)`,驗證 MSSQL;失敗 dispose owner。session 不獨立 Dispose 同一 SqlConnection,command/transaction 留來源層管理。不得使用 ConnectionString 另開第二條連線。 +- [x] **4. 跑 session group 為綠,確認既有 connection tests 仍過。** 只用 fake owner/未開啟連線完成生命週期單元測試;真 SSH 另列 Task 10。 +- [x] **5. 檢查 diff 與提交。** 僅 stage 本 task 明確檔案;建議訊息 `feat(mssql-export): own export session and immutable requests`。不 push。 ## Task 2:原生 metadata、預檢與快照來源 From af30227bbae14790b8fd6cd266f24909739de818 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:13:24 +0800 Subject: [PATCH 04/32] feat(mssql-export): collect native metadata and preflight snapshot export --- .../MssqlExport/MssqlExportMetadataReader.cs | 188 ++++++++++++++++++ .../lib/MssqlExport/MssqlExportModels.cs | 145 ++++++++++++++ .../lib/MssqlExport/MssqlExportSource.cs | 133 +++++++++++++ mySQLPunk/mySQLPunk.csproj | 2 + tests/SmokeTests.MssqlExport.cs | 85 ++++++++ tests/SmokeTests.cs | 3 + 6 files changed, 556 insertions(+) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportSource.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs new file mode 100644 index 0000000..4208a7e --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Globalization; +using System.Linq; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + internal sealed class MssqlExportMetadataReader + { + internal static string Quote(string name) { return "[" + name.Replace("]", "]]") + "]"; } + internal static string Qualified(MssqlObjectKey key) { return Quote(key.Schema) + "." + Quote(key.Name); } + internal MssqlExportCatalog ReadCatalog(SqlConnection connection, string databaseName, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + connection.ChangeDatabase(databaseName); + EnsureVisibility(connection, null, cancellation); + var tables = ReadTables(connection, null, cancellation); + var issues = tables.Where(t => !t.Flag("can_select")).Select(t => new MssqlExportIssue("UnreadableTable", t.Key, null, "SELECT permission is required.")); + return new MssqlExportCatalog(tables, Snapshot(connection, null, cancellation), issues); + } + internal MssqlExportSchema ReadSchema(SqlConnection connection, SqlTransaction transaction, MssqlExportRequest request, CancellationToken cancellation) + { + EnsureVisibility(connection, transaction, cancellation); + var issues = new List(); + var selected = new List(); + var catalog = ReadTables(connection, transaction, cancellation); + foreach (var key in request.Tables) + { + var table = catalog.SingleOrDefault(t => t.Key.ObjectId == key.ObjectId && t.Key.Schema == key.Schema && t.Key.Name == key.Name); + if (table == null) { issues.Add(new MssqlExportIssue("ObjectChanged", key, null, "Selected table is missing, renamed or replaced.")); continue; } + string id = key.ObjectId.ToString(CultureInfo.InvariantCulture); + var columnSql = @"SELECT c.column_id,c.name,ty.name type_name,SCHEMA_NAME(ty.schema_id) type_schema,ty.is_user_defined,ty.is_assembly_type, +c.max_length,c.precision,c.scale,c.collation_name,c.is_nullable,c.is_ansi_padded,c.is_identity,c.is_computed,c.is_filestream,c.is_sparse,c.is_column_set,c.is_rowguidcol,c.xml_collection_id,c.is_xml_document,c.rule_object_id, +CONVERT(nvarchar(100),ic.seed_value) seed_value,CONVERT(nvarchar(100),ic.increment_value) increment_value,ic.is_not_for_replication identity_not_for_replication, +cc.definition computed_definition,cc.is_persisted,dc.name default_name,dc.definition default_definition, +c.default_object_id,CONVERT(nvarchar(max),ep.value) comment," + Optional(connection, transaction, "sys.columns", "c", cancellation, "generated_always_type", "encryption_type", "is_hidden", "vector_dimensions") + "," + (HasCatalog(connection, transaction, "masked_columns", cancellation) ? "(SELECT MAX(CONVERT(int,mc.is_masked)) FROM sys.masked_columns mc WHERE mc.object_id=c.object_id AND mc.column_id=c.column_id)" : "0") + " is_masked" + @" +FROM sys.columns c JOIN sys.types ty ON c.user_type_id=ty.user_type_id +LEFT JOIN sys.identity_columns ic ON ic.object_id=c.object_id AND ic.column_id=c.column_id +LEFT JOIN sys.computed_columns cc ON cc.object_id=c.object_id AND cc.column_id=c.column_id +LEFT JOIN sys.default_constraints dc ON dc.object_id=c.default_object_id +LEFT JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=c.object_id AND ep.minor_id=c.column_id AND ep.name=N'MS_Description' +WHERE c.object_id=" + id + " ORDER BY c.column_id"; + var columns = Query(connection, transaction, columnSql, cancellation).Select(r => new MssqlColumnDefinition(r)).ToList(); + var indexes = new List(); + foreach (var r in Query(connection, transaction, @"SELECT i.index_id,i.name,i.type,i.is_unique,i.is_primary_key,i.is_unique_constraint,i.is_disabled,i.is_hypothetical, +i.fill_factor,i.is_padded,i.ignore_dup_key,i.allow_row_locks,i.allow_page_locks,i.has_filter,i.filter_definition, +ds.name data_space_name,ds.type data_space_type,s.no_recompute," + Optional(connection, transaction, "sys.indexes", "i", cancellation, "optimize_for_sequential_key", "suppress_dup_key_messages") + @", +(SELECT MAX(p.data_compression) FROM sys.partitions p WHERE p.object_id=i.object_id AND p.index_id=i.index_id) data_compression +FROM sys.indexes i LEFT JOIN sys.data_spaces ds ON ds.data_space_id=i.data_space_id +LEFT JOIN sys.stats s ON s.object_id=i.object_id AND s.stats_id=i.index_id +WHERE i.object_id=" + id + " ORDER BY i.index_id", cancellation)) + { + var f = new MssqlNativeFacts(r); + var cols = Query(connection, transaction, "SELECT ic.index_column_id,ic.column_id,c.name,ic.key_ordinal,ic.is_descending_key,ic.is_included_column,ic.partition_ordinal FROM sys.index_columns ic JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id WHERE ic.object_id=" + id + " AND ic.index_id=" + f.Number("index_id") + " ORDER BY ic.index_column_id", cancellation).Select(x => new MssqlNativeFacts(x)); + indexes.Add(new MssqlIndexDefinition(r, cols)); + } + var constraints = new List(); + foreach (var r in Query(connection, transaction, "SELECT object_id,name,'CHECK' kind,definition,is_disabled,is_not_trusted,is_not_for_replication,parent_column_id,uses_database_collation FROM sys.check_constraints WHERE parent_object_id=" + id + " ORDER BY object_id", cancellation)) constraints.Add(new MssqlConstraintDefinition(r, null)); + foreach (var r in Query(connection, transaction, @"SELECT fk.object_id,fk.name,'FK' kind,fk.referenced_object_id,SCHEMA_NAME(o.schema_id) referenced_schema,o.name referenced_table, +fk.delete_referential_action_desc,fk.update_referential_action_desc,fk.is_disabled,fk.is_not_trusted,fk.is_not_for_replication +FROM sys.foreign_keys fk JOIN sys.objects o ON o.object_id=fk.referenced_object_id WHERE fk.parent_object_id=" + id + " ORDER BY fk.object_id", cancellation)) + { + var f = new MssqlNativeFacts(r); + var cols = Query(connection, transaction, @"SELECT f.constraint_column_id,pc.name parent_column,rc.name referenced_column FROM sys.foreign_key_columns f +JOIN sys.columns pc ON pc.object_id=f.parent_object_id AND pc.column_id=f.parent_column_id +JOIN sys.columns rc ON rc.object_id=f.referenced_object_id AND rc.column_id=f.referenced_column_id WHERE f.constraint_object_id=" + f.Number("object_id") + " ORDER BY f.constraint_column_id", cancellation).Select(x => new MssqlNativeFacts(x)); + constraints.Add(new MssqlConstraintDefinition(r, cols)); + } + selected.Add(new MssqlTableDefinition(key, table.Facts.ToDictionary(x => x.Key, x => x.Value), columns, indexes, constraints)); + } + var views = new List(); + if (request.IncludeViews) + foreach (var r in Query(connection, transaction, @"SELECT v.object_id,SCHEMA_NAME(v.schema_id) schema_name,v.name,m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier,m.is_schema_bound,v.with_check_option, +(SELECT COUNT(*) FROM sys.indexes i WHERE i.object_id=v.object_id AND i.index_id>0) index_count, +(SELECT COUNT(*) FROM sys.triggers t WHERE t.parent_id=v.object_id) trigger_count, +(SELECT COUNT(*) FROM sys.extended_properties e WHERE e.class=1 AND e.major_id=v.object_id) unsupported_properties +FROM sys.views v LEFT JOIN sys.sql_modules m ON m.object_id=v.object_id WHERE v.is_ms_shipped=0 ORDER BY v.object_id", cancellation)) + { var f = new MssqlNativeFacts(r); views.Add(new MssqlViewDefinition(new MssqlObjectKey(f.Number("object_id"), f.Text("schema_name"), f.Text("name")), r)); } + var objectIds = new HashSet(selected.Select(x => x.Key.ObjectId).Concat(views.Select(x => x.Key.ObjectId))); + var dependencies = new List(); + foreach (var r in Query(connection, transaction, @"SELECT CASE WHEN o.parent_object_id<>0 THEN o.parent_object_id ELSE d.referencing_id END object_id, +d.referenced_id,d.referenced_server_name,d.referenced_database_name,d.referenced_schema_name,d.referenced_entity_name +FROM sys.sql_expression_dependencies d JOIN sys.objects o ON o.object_id=d.referencing_id WHERE d.referenced_class=1", cancellation)) + { var f = new MssqlNativeFacts(r); if (objectIds.Contains(f.Number("object_id"))) dependencies.Add(new MssqlDependency(f.Number("object_id"), f.Text("referenced_id") == null ? (int?)null : f.Number("referenced_id"), f.Text("referenced_server_name"), f.Text("referenced_database_name"), f.Text("referenced_schema_name"), f.Text("referenced_entity_name"))); } + return new MssqlExportSchema(selected, views, dependencies, Snapshot(connection, transaction, cancellation), issues); + } + internal IReadOnlyList Validate(MssqlExportSchema schema, MssqlExportRequest request) + { + var issues = new List(schema.Issues); + if (request.Mode != MssqlExportMode.SchemaOnly && schema.SnapshotState != 1) issues.Add(new MssqlExportIssue("SnapshotUnavailable", null, null, "ALLOW_SNAPSHOT_ISOLATION must be ON (state 1); OFF and transitions cannot export data.")); + var ids = new HashSet(schema.Tables.Select(x => x.Key.ObjectId).Concat(schema.Views.Select(x => x.Key.ObjectId))); + foreach (var t in schema.Tables) + { + Action add = (code, col, reason) => issues.Add(new MssqlExportIssue(code, t.Key, col, reason)); + if (t.Text("can_select") != null && !t.Flag("can_select")) add("UnreadableTable", null, "SELECT permission is required."); + foreach (var flag in new[] { "temporal_type", "ledger_type", "is_memory_optimized", "is_filetable", "is_node", "is_edge", "is_external", "is_remote_data_archive_enabled", "trigger_count", "fulltext_count", "security_policy_count", "unsupported_properties" }) + if (t.Number(flag) != 0) add("UnsupportedTableFeature", null, "Unsupported table feature: " + flag); + foreach (var c in t.Columns) + { + if (c.NativeType == "geometry" || c.NativeType == "geography") add("UnsupportedSpatial", c.Name, "Native spatial values require a future codec."); + else if (c.Flag("is_user_defined") || c.Flag("is_assembly_type")) add("UnsupportedUdt", c.Name, "Alias and CLR user types are not supported."); + if (c.Number("xml_collection_id") != 0) add("UnsupportedTypedXml", c.Name, "Typed XML schema collections are not exported."); + if (c.NativeType == "sql_variant" || !SupportedTypes.Contains(c.NativeType ?? "")) add("UnsupportedType", c.Name, "Unsupported native type: " + c.NativeType); + foreach (var flag in new[] { "is_filestream", "is_sparse", "is_column_set", "generated_always_type", "encryption_type", "is_hidden", "is_masked", "rule_object_id", "vector_dimensions" }) + if (c.Flag(flag) || c.Number(flag) != 0) add("UnsupportedColumnFeature", c.Name, "Unsupported column feature: " + flag); + if (c.Computed && c.ComputedDefinition == null) add("UnreadableDefinition", c.Name, "Computed definition is unavailable."); + if (c.Number("default_object_id") != 0 && c.DefaultDefinition == null) add("UnreadableDefinition", c.Name, "Default is hidden or is a legacy bound default."); + } + foreach (var i in t.Indexes) + { + if (i.Number("type") > 2 || i.Text("data_space_type") == "PS" || i.Number("data_compression") != 0 || i.Flag("is_hypothetical")) add("UnsupportedIndex", null, "Unsupported index/partition/compression: " + i.Name); + if (i.Flag("has_filter") && i.Text("filter_definition") == null) add("UnreadableDefinition", null, "Index filter unavailable: " + i.Name); + } + foreach (var c in t.Constraints) + { + if (c.Kind == "CHECK" && c.Text("definition") == null) add("UnreadableDefinition", null, "CHECK definition unavailable: " + c.Name); + if (c.Kind == "FK" && !ids.Contains(c.Number("referenced_object_id")) && request.Mode != MssqlExportMode.DataOnly) add("MissingDependency", null, "Foreign key " + c.Name + " requires " + c.Text("referenced_schema") + "." + c.Text("referenced_table")); + } + } + foreach (var v in schema.Views) + { + if (string.IsNullOrEmpty(v.Definition)) issues.Add(new MssqlExportIssue("UnreadableDefinition", v.Key, null, "View definition is encrypted or unreadable.")); + if (v.Number("index_count") != 0 || v.Number("trigger_count") != 0 || v.Number("unsupported_properties") != 0) issues.Add(new MssqlExportIssue("UnsupportedViewFeature", v.Key, null, "Indexed views, view triggers and view extended properties are unsupported.")); + } + if (request.Mode != MssqlExportMode.DataOnly) + foreach (var d in schema.Dependencies) + if (d.Server != null || d.Database != null || !d.ReferencedId.HasValue || !ids.Contains(d.ReferencedId.Value)) + issues.Add(new MssqlExportIssue("MissingDependency", schema.Tables.Select(x => x.Key).Concat(schema.Views.Select(x => x.Key)).FirstOrDefault(x => x.ObjectId == d.ObjectId), null, "Definition requires unexported object: " + string.Join(".", new[] { d.Server, d.Database, d.Schema, d.Name }.Where(x => x != null)))); + if (schema.Tables.GroupBy(x => x.Key.ObjectId).Any(g => g.Count() > 1)) + issues.Add(new MssqlExportIssue("DuplicateSelection", null, null, "Each table may be selected only once.")); + var pending = new HashSet(schema.Views.Select(x => x.Key.ObjectId)); + while (pending.Count != 0) + { + var ready = pending.Where(id => !schema.Dependencies.Any(d => d.ObjectId == id && d.ReferencedId.HasValue && pending.Contains(d.ReferencedId.Value))).ToList(); + if (ready.Count == 0) { issues.Add(new MssqlExportIssue("DependencyCycle", null, null, "View dependencies contain a cycle and cannot be scripted in dependency order.")); break; } + foreach (int id in ready) pending.Remove(id); + } + return issues.AsReadOnly(); + } + private static readonly HashSet SupportedTypes = new HashSet("bigint int smallint tinyint bit decimal numeric money smallmoney float real date time datetime datetime2 smalldatetime datetimeoffset char varchar text nchar nvarchar ntext binary varbinary image uniqueidentifier xml timestamp rowversion".Split(' '), StringComparer.OrdinalIgnoreCase); + private static void EnsureVisibility(SqlConnection c, SqlTransaction t, CancellationToken token) + { + var row = Query(c, t, "SELECT HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION') allowed", token)[0]; + if (!new MssqlNativeFacts(row).Flag("allowed")) throw new InvalidOperationException("Database VIEW DEFINITION permission is required to detect hidden dependencies and definitions."); + } + private static int Snapshot(SqlConnection c, SqlTransaction t, CancellationToken token) { return new MssqlNativeFacts(Query(c, t, "SELECT snapshot_isolation_state FROM sys.databases WHERE database_id=DB_ID()", token).Single()).Number("snapshot_isolation_state"); } + private static bool HasCatalog(SqlConnection c, SqlTransaction t, string name, CancellationToken token) + { return Query(c, t, "SELECT object_id FROM sys.all_objects WHERE name='" + name + "' AND schema_id=SCHEMA_ID('sys')", token).Count != 0; } + private static List ReadTables(SqlConnection c, SqlTransaction t, CancellationToken token) + { + string optional = Optional(c, t, "sys.tables", "t", token, "temporal_type", "ledger_type", "is_memory_optimized", "is_filetable", "is_node", "is_edge", "is_external", "is_remote_data_archive_enabled"); + bool security = Query(c, t, "SELECT object_id FROM sys.all_objects WHERE name='security_predicates' AND schema_id=SCHEMA_ID('sys')", token).Count != 0; + string sql = @"SELECT t.object_id,SCHEMA_NAME(t.schema_id) schema_name,t.name,t.uses_ansi_nulls,t.lock_escalation_desc,t.text_in_row_limit,t.large_value_types_out_of_row,CONVERT(nvarchar(max),ep.value) comment, +HAS_PERMS_BY_NAME(QUOTENAME(SCHEMA_NAME(t.schema_id))+'.'+QUOTENAME(t.name),'OBJECT','SELECT') can_select, +(SELECT SUM(p.rows) FROM sys.partitions p WHERE p.object_id=t.object_id AND p.index_id IN(0,1)) estimated_rows, +CAST(NULL AS bigint) estimated_bytes, +(SELECT COUNT(*) FROM sys.triggers tr WHERE tr.parent_id=t.object_id) trigger_count, +(SELECT COUNT(*) FROM sys.fulltext_indexes f WHERE f.object_id=t.object_id) fulltext_count, +(SELECT COUNT(*) FROM sys.extended_properties e WHERE e.class=1 AND e.major_id=t.object_id AND e.name<>N'MS_Description') unsupported_properties," + +(security ? "(SELECT COUNT(*) FROM sys.security_predicates sp WHERE sp.target_object_id=t.object_id)" : "0") + " security_policy_count," + optional + @" +FROM sys.tables t LEFT JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=t.object_id AND ep.minor_id=0 AND ep.name=N'MS_Description' +WHERE t.is_ms_shipped=0 ORDER BY t.object_id"; + return Query(c, t, sql, token).Select(r => { var f = new MssqlNativeFacts(r); return new MssqlTableDefinition(new MssqlObjectKey(f.Number("object_id"), f.Text("schema_name"), f.Text("name")), r, null, null, null); }).ToList(); + } + // Probe catalog columns first: SQL Server still compiles a missing column inside CASE. + private static string Optional(SqlConnection c, SqlTransaction t, string catalog, string alias, CancellationToken token, params string[] names) + { + var available = new HashSet(Query(c, t, "SELECT name FROM sys.all_columns WHERE object_id=OBJECT_ID(N'" + catalog + "')", token).Select(r => Convert.ToString(r["name"], CultureInfo.InvariantCulture)), StringComparer.OrdinalIgnoreCase); + return string.Join(",", names.Select(n => available.Contains(n) ? alias + "." + Quote(n) : "CAST(0 AS int) " + Quote(n))); + } + internal static List> Query(SqlConnection connection, SqlTransaction transaction, string sql, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var result = new List>(); + using (var command = new SqlCommand(sql, connection, transaction) { CommandTimeout = 0 }) + using (token.Register(() => { try { command.Cancel(); } catch (ObjectDisposedException) { } })) + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { token.ThrowIfCancellationRequested(); var row = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < reader.FieldCount; i++) row.Add(reader.GetName(i), reader.IsDBNull(i) ? null : reader.GetValue(i)); result.Add(row); } + } + token.ThrowIfCancellationRequested(); return result; + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs index 0a781f8..1bb0d59 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -1,6 +1,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Globalization; +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text; namespace mySQLPunk.lib.MssqlExport { @@ -84,4 +89,144 @@ internal static class MssqlExportLimits public const int BinaryChunkBytes = 65536; public const int TextChunkCodeUnits = 16384; } + // Native catalog facts are copied once. Extra SQL Server properties stay accessible + // without lossy cross-provider type mapping; writers use the typed accessors below. + internal class MssqlNativeFacts + { + public IReadOnlyDictionary Facts { get; } + internal MssqlNativeFacts(IDictionary facts) + { + Facts = new ReadOnlyDictionary(new Dictionary(facts ?? new Dictionary(), StringComparer.Ordinal)); + } + public string Text(string name) { object value; return Facts.TryGetValue(name, out value) && value != null && value != DBNull.Value ? Convert.ToString(value, CultureInfo.InvariantCulture) : null; } + public int Number(string name) { var value = Text(name); return value == null || value == "False" ? 0 : value == "True" ? 1 : int.Parse(value, CultureInfo.InvariantCulture); } + public bool Flag(string name) { var value = Text(name); return value == "1" || string.Equals(value, "True", StringComparison.OrdinalIgnoreCase); } + public long? Estimate(string name) { var value = Text(name); return value == null ? (long?)null : long.Parse(value, CultureInfo.InvariantCulture); } + internal void Canonical(StringBuilder output) + { + foreach (var fact in Facts.Where(x => x.Key != "estimated_rows" && x.Key != "estimated_bytes").OrderBy(x => x.Key, StringComparer.Ordinal)) + { Part(output, fact.Key); Part(output, Text(fact.Key)); } + } + internal static void Part(StringBuilder output, string text) { output.Append(text == null ? -1 : text.Length).Append(':').Append(text); } + internal static IReadOnlyList Copy(IEnumerable values) { return new List(values ?? Enumerable.Empty()).AsReadOnly(); } + } + internal sealed class MssqlColumnDefinition : MssqlNativeFacts + { + internal MssqlColumnDefinition(IDictionary facts) : base(facts) { } + public int Ordinal => Number("column_id"); + public string Name => Text("name"); + public string NativeType => Text("type_name"); + public string TypeSchema => Text("type_schema"); + public int Length => Number("max_length"); + public int Precision => Number("precision"); + public int Scale => Number("scale"); + public string Collation => Text("collation_name"); + public bool Nullable => Flag("is_nullable"); + public bool Identity => Flag("is_identity"); + public string IdentitySeed => Text("seed_value"); + public string IdentityIncrement => Text("increment_value"); + public bool Computed => Flag("is_computed"); + public string ComputedDefinition => Text("computed_definition"); + public bool Persisted => Flag("is_persisted"); + public bool RowVersion => NativeType == "timestamp" || NativeType == "rowversion"; + public string DefaultName => Text("default_name"); + public string DefaultDefinition => Text("default_definition"); + public string Comment => Text("comment"); + } + internal sealed class MssqlIndexDefinition : MssqlNativeFacts + { + public IReadOnlyList Columns { get; } + internal MssqlIndexDefinition(IDictionary facts, IEnumerable columns) : base(facts) { Columns = Copy(columns); } + public string Name => Text("name"); + public int IndexId => Number("index_id"); + } + internal sealed class MssqlConstraintDefinition : MssqlNativeFacts + { + public IReadOnlyList Columns { get; } + internal MssqlConstraintDefinition(IDictionary facts, IEnumerable columns) : base(facts) { Columns = Copy(columns); } + public string Name => Text("name"); + public string Kind => Text("kind"); + } + internal sealed class MssqlTableDefinition : MssqlNativeFacts + { + public MssqlObjectKey Key { get; } + public IReadOnlyList Columns { get; } + public IReadOnlyList Indexes { get; } + public IReadOnlyList Constraints { get; } + internal MssqlTableDefinition(MssqlObjectKey key, IDictionary facts, IEnumerable columns, IEnumerable indexes, IEnumerable constraints) : base(facts) + { Key = key; Columns = Copy(columns); Indexes = Copy(indexes); Constraints = Copy(constraints); } + public string Comment => Text("comment"); + public long? EstimatedRows => Estimate("estimated_rows"); + public long? EstimatedBytes => Estimate("estimated_bytes"); + } + internal sealed class MssqlViewDefinition : MssqlNativeFacts + { + public MssqlObjectKey Key { get; } + internal MssqlViewDefinition(MssqlObjectKey key, IDictionary facts) : base(facts) { Key = key; } + public string Definition => Text("definition"); + public bool AnsiNulls => Flag("uses_ansi_nulls"); + public bool QuotedIdentifier => Flag("uses_quoted_identifier"); + } + internal sealed class MssqlDependency + { + public int ObjectId { get; } + public int? ReferencedId { get; } + public string Server { get; } + public string Database { get; } + public string Schema { get; } + public string Name { get; } + internal MssqlDependency(int objectId, int? referencedId, string server, string database, string schema, string name) + { ObjectId = objectId; ReferencedId = referencedId; Server = server; Database = database; Schema = schema; Name = name; } + } + internal sealed class MssqlExportIssue + { + public string Code { get; } + public MssqlObjectKey ObjectKey { get; } + public string ColumnName { get; } + public string Message { get; } + internal MssqlExportIssue(string code, MssqlObjectKey key, string column, string message) { Code = code; ObjectKey = key; ColumnName = column; Message = message; } + } + internal sealed class MssqlExportCatalog + { + public IReadOnlyList Tables { get; } + public int SnapshotState { get; } + public IReadOnlyList Issues { get; } + internal MssqlExportCatalog(IEnumerable tables, int snapshotState, IEnumerable issues) + { Tables = MssqlNativeFacts.Copy(tables); SnapshotState = snapshotState; Issues = MssqlNativeFacts.Copy(issues); } + } + internal sealed class MssqlExportSchema + { + public IReadOnlyList Tables { get; } + public IReadOnlyList Views { get; } + public IReadOnlyList Dependencies { get; } + public IReadOnlyList Issues { get; } + public int SnapshotState { get; } + public string Fingerprint { get; } + internal MssqlExportSchema(IEnumerable tables, IEnumerable views, IEnumerable dependencies, int snapshotState, IEnumerable issues) + { + Tables = MssqlNativeFacts.Copy(tables); Views = MssqlNativeFacts.Copy(views); Dependencies = MssqlNativeFacts.Copy(dependencies); Issues = MssqlNativeFacts.Copy(issues); SnapshotState = snapshotState; + var text = new StringBuilder(); + foreach (var t in Tables.OrderBy(x => x.Key.ObjectId)) + { + Key(text, t.Key); t.Canonical(text); + foreach (var c in t.Columns.OrderBy(x => x.Ordinal)) c.Canonical(text); + foreach (var i in t.Indexes.OrderBy(x => x.IndexId)) { i.Canonical(text); foreach (var c in i.Columns) c.Canonical(text); } + foreach (var c in t.Constraints.OrderBy(x => x.Name, StringComparer.Ordinal)) { c.Canonical(text); foreach (var column in c.Columns) column.Canonical(text); } + } + foreach (var v in Views.OrderBy(x => x.Key.ObjectId)) { Key(text, v.Key); v.Canonical(text); } + foreach (var d in Dependencies.OrderBy(x => x.ObjectId).ThenBy(x => x.ReferencedId).ThenBy(x => x.Name, StringComparer.Ordinal)) + { MssqlNativeFacts.Part(text, d.ObjectId.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.ReferencedId?.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.Server); MssqlNativeFacts.Part(text, d.Database); MssqlNativeFacts.Part(text, d.Schema); MssqlNativeFacts.Part(text, d.Name); } + using (var sha = SHA256.Create()) Fingerprint = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()))).Replace("-", ""); + } + private static void Key(StringBuilder text, MssqlObjectKey key) { MssqlNativeFacts.Part(text, key.ObjectId.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, key.Schema); MssqlNativeFacts.Part(text, key.Name); } + } + internal sealed class MssqlExportPreflight + { + public MssqlExportSchema Schema { get; } + public int SnapshotState => Schema.SnapshotState; + public IReadOnlyList Issues { get; } + public bool CanStart => Issues.Count == 0; + internal MssqlExportPreflight(MssqlExportSchema schema, IEnumerable issues) { Schema = schema; Issues = MssqlNativeFacts.Copy(issues); } + } + } diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs new file mode 100644 index 0000000..50edb85 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs @@ -0,0 +1,133 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + internal interface IMssqlExportSource : IDisposable + { + MssqlExportCatalog ReadCatalog(string databaseName, CancellationToken cancellation); + MssqlExportPreflight Inspect(MssqlExportRequest request, CancellationToken cancellation); + MssqlExportSchema BeginRead(MssqlExportRequest request, CancellationToken cancellation); + MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken cancellation); + void CompleteRead(MssqlExportSchema schema, CancellationToken cancellation); + } + internal sealed class MssqlRowReader : IDisposable + { + private IDisposable commandOwner; + public DbDataReader Reader { get; } + internal bool IsDisposed { get; private set; } + internal MssqlRowReader(DbDataReader reader, IDisposable commandOwner) + { Reader = reader ?? throw new ArgumentNullException(nameof(reader)); this.commandOwner = commandOwner ?? throw new ArgumentNullException(nameof(commandOwner)); } + public void Dispose() + { + if (IsDisposed) return; + IsDisposed = true; + try { Reader.Dispose(); } finally { var owner = commandOwner; commandOwner = null; owner.Dispose(); } + } + } + internal sealed class MssqlExportSource : IMssqlExportSource + { + private readonly MssqlOwnedSession session; + private readonly MssqlExportMetadataReader metadata = new MssqlExportMetadataReader(); + private SqlTransaction transaction; + private MssqlExportRequest request; + private MssqlExportSchema frozen; + private MssqlRowReader activeReader; + private bool disposed; + internal MssqlExportSource(MssqlOwnedSession session) { this.session = session ?? throw new ArgumentNullException(nameof(session)); } + public MssqlExportCatalog ReadCatalog(string databaseName, CancellationToken cancellation) + { CheckIdle(); try { return metadata.ReadCatalog(session.Connection, databaseName, cancellation); } catch { Dispose(); throw; } } + public MssqlExportPreflight Inspect(MssqlExportRequest value, CancellationToken cancellation) + { + CheckIdle(); + try { cancellation.ThrowIfCancellationRequested(); session.Connection.ChangeDatabase(value.DatabaseName); var schema = metadata.ReadSchema(session.Connection, null, value, cancellation); return new MssqlExportPreflight(schema, metadata.Validate(schema, value)); } + catch { Dispose(); throw; } + } + public MssqlExportSchema BeginRead(MssqlExportRequest value, CancellationToken cancellation) + { + CheckIdle(); + try + { + var preflight = Inspect(value, cancellation); + RequireValid(preflight); + request = value; + cancellation.ThrowIfCancellationRequested(); + if (value.Mode != MssqlExportMode.SchemaOnly) transaction = session.Connection.BeginTransaction(IsolationLevel.Snapshot); + frozen = metadata.ReadSchema(session.Connection, transaction, value, cancellation); + RequireValid(new MssqlExportPreflight(frozen, metadata.Validate(frozen, value))); + return frozen; + } + catch { Dispose(); throw; } + } + public MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken cancellation) + { + CheckAlive(); + try + { + if (frozen == null || transaction == null) throw new InvalidOperationException("A data snapshot must be started first."); + if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Dispose the current row reader first."); + if (!frozen.Tables.Any(t => ReferenceEquals(t, table))) throw new ArgumentException("Table must belong to this frozen schema.", nameof(table)); + cancellation.ThrowIfCancellationRequested(); + var columns = table.Columns.Where(c => !c.Computed && !c.RowVersion).OrderBy(c => c.Ordinal).ToList(); + // A rowversion/computed-only table still needs a row count for DEFAULT VALUES. + string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(c => MssqlExportMetadataReader.Quote(c.Name))); + var command = new SqlCommand("SELECT " + projection + " FROM " + MssqlExportMetadataReader.Qualified(table.Key), session.Connection, transaction) { CommandTimeout = 0 }; + var owner = new MssqlCommandOwner(command, cancellation); + try { activeReader = new MssqlRowReader(command.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } + catch { owner.Dispose(); throw; } + } + catch { Dispose(); throw; } + } + public void CompleteRead(MssqlExportSchema schema, CancellationToken cancellation) + { + CheckAlive(); + try + { + if (frozen == null || !ReferenceEquals(schema, frozen)) throw new InvalidOperationException("Schema does not belong to this read."); + if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Close the row reader before completing the read."); + cancellation.ThrowIfCancellationRequested(); + var current = metadata.ReadSchema(session.Connection, transaction, request, cancellation); + RequireValid(new MssqlExportPreflight(current, metadata.Validate(current, request))); + if (current.Fingerprint != frozen.Fingerprint) throw new InvalidOperationException("Source definitions changed during export; discard this export."); + cancellation.ThrowIfCancellationRequested(); + if (transaction != null) { transaction.Commit(); transaction.Dispose(); transaction = null; } + } + finally { Dispose(); } + } + private static void RequireValid(MssqlExportPreflight preflight) + { if (!preflight.CanStart) throw new InvalidOperationException(string.Join(Environment.NewLine, preflight.Issues.Select(x => x.Code + ": " + x.Message))); } + private void CheckAlive() { if (disposed) throw new ObjectDisposedException(nameof(MssqlExportSource)); } + private void CheckIdle() { CheckAlive(); if (request != null) throw new InvalidOperationException("A read is already active."); } + public void Dispose() + { + if (disposed) return; disposed = true; + try { if (activeReader != null) activeReader.Dispose(); } + finally + { + try { if (transaction != null) { try { transaction.Rollback(); } catch (InvalidOperationException) { } finally { transaction.Dispose(); transaction = null; } } } + finally { session.Dispose(); } + } + } + } + internal sealed class MssqlCommandOwner : IDisposable + { + private DbCommand command; + private CancellationTokenRegistration registration; + internal MssqlCommandOwner(DbCommand command, CancellationToken cancellation) + { + this.command = command; + registration = cancellation.Register(() => { try { command.Cancel(); } catch (ObjectDisposedException) { } }); + } + public void Dispose() + { + var value = command; + if (value == null) return; + command = null; + try { registration.Dispose(); } finally { value.Dispose(); } + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 29e7f95..8a02726 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -361,6 +361,8 @@ + + diff --git a/tests/SmokeTests.MssqlExport.cs b/tests/SmokeTests.MssqlExport.cs index c399c68..1df5a61 100644 --- a/tests/SmokeTests.MssqlExport.cs +++ b/tests/SmokeTests.MssqlExport.cs @@ -8,6 +8,91 @@ public static partial class SmokeTests { + private static void Metadata_RequiresNativeReader() + { + Assert(typeof(MssqlObjectKey).Assembly.GetType("mySQLPunk.lib.MssqlExport.MssqlExportMetadataReader") != null, "Native metadata reader must exist"); + } + + private static void Metadata_MapsNativeFactsAndValidates() + { + var a = new MssqlObjectKey(101, "dbo", "ord]ers"); + var b = new MssqlObjectKey(102, "sales", "ord]ers"); + var shape = new MssqlColumnDefinition(new Dictionary { { "column_id", 1 }, { "name", "shape" }, { "type_name", "geometry" }, { "xml_collection_id", 0 } }); + var xml = new MssqlColumnDefinition(new Dictionary { { "column_id", 2 }, { "name", "xml" }, { "type_name", "xml" }, { "xml_collection_id", 9 } }); + var udt = new MssqlColumnDefinition(new Dictionary { { "column_id", 3 }, { "name", "custom" }, { "type_name", "MyType" }, { "is_user_defined", true } }); + var tables = new[] { new MssqlTableDefinition(a, new Dictionary(), new[] { shape, xml, udt }, null, null), new MssqlTableDefinition(b, new Dictionary(), null, null, null) }; + var catalog = new MssqlExportCatalog(tables, 0, null); + Assert(catalog.Tables.Count == 2, "dbo and sales objects remain distinct"); + Assert(catalog.Tables[0].EstimatedRows == null && catalog.Tables[0].EstimatedBytes == null, "Missing estimates remain unknown"); + Assert(MssqlExportMetadataReader.Quote(a.Name) == "[ord]]ers]", "Quote each identifier segment"); + var request = new MssqlExportRequest(Guid.NewGuid(), "fixture", new[] { a, b }, MssqlExportMode.SchemaAndData, false, Path.Combine(Path.GetTempPath(), "fixture.sql"), null, null); + var schema = new MssqlExportSchema(tables, null, new[] { new MssqlDependency(a.ObjectId, 999, null, null, "dbo", "missing") }, 0, null); + var issues = new MssqlExportMetadataReader().Validate(schema, request); + Assert(System.Linq.Enumerable.Any(issues, x => x.Code == "UnsupportedSpatial" && x.ColumnName == "shape"), "Spatial issue names column"); + Assert(System.Linq.Enumerable.Any(issues, x => x.Code == "UnsupportedTypedXml"), "Typed XML rejected"); + Assert(System.Linq.Enumerable.Any(issues, x => x.Code == "UnsupportedUdt"), "UDT rejected"); + Assert(System.Linq.Enumerable.Any(issues, x => x.Code == "MissingDependency"), "Dependencies must be selected"); + for (int state = 0; state <= 3; state++) + { + var candidate = new MssqlExportSchema(tables, null, null, state, null); + Assert(System.Linq.Enumerable.Any(new MssqlExportMetadataReader().Validate(candidate, request), x => x.Code == "SnapshotUnavailable") == (state != 1), "Only ON accepts data snapshot"); + } + var v1 = new MssqlViewDefinition(new MssqlObjectKey(201, "dbo", "v1"), new Dictionary { { "definition", "CREATE VIEW dbo.v1 AS SELECT * FROM dbo.v2" } }); + var v2 = new MssqlViewDefinition(new MssqlObjectKey(202, "dbo", "v2"), new Dictionary { { "definition", "CREATE VIEW dbo.v2 AS SELECT * FROM dbo.v1" } }); + var cycle = new MssqlExportSchema(tables, new[] { v1, v2 }, new[] { new MssqlDependency(201, 202, null, null, "dbo", "v2"), new MssqlDependency(202, 201, null, null, "dbo", "v1") }, 1, null); + Assert(System.Linq.Enumerable.Any(new MssqlExportMetadataReader().Validate(cycle, request), x => x.Code == "DependencyCycle"), "Cyclic view definitions cannot be ordered for restore"); + var duplicate = new MssqlExportSchema(new[] { tables[0], tables[0] }, null, null, 1, null); + Assert(System.Linq.Enumerable.Any(new MssqlExportMetadataReader().Validate(duplicate, request), x => x.Code == "DuplicateSelection"), "Repeated table selection cannot duplicate output"); + var changed = new MssqlExportSchema(new[] { new MssqlTableDefinition(a, new Dictionary { { "comment", "changed" } }, new[] { shape, xml, udt }, null, null), tables[1] }, null, schema.Dependencies, 0, null); + Assert(schema.Fingerprint != changed.Fingerprint, "Definition fingerprint includes comments"); + var estimated = new MssqlExportSchema(new[] { new MssqlTableDefinition(a, new Dictionary { { "estimated_rows", 123L } }, new[] { shape, xml, udt }, null, null), tables[1] }, null, schema.Dependencies, 0, null); + Assert(schema.Fingerprint == estimated.Fingerprint, "Changing row estimate is not schema drift"); + } + + private static void Metadata_DisposesReaderAndCancellationBeforeCommand() + { + var events = new List(); + var cancellation = new System.Threading.CancellationTokenSource(); + var command = new MetadataCommand(events, cancellation); + var owner = new MssqlCommandOwner(command, cancellation.Token); + var table = new DataTable(); table.Columns.Add("id", typeof(int)); table.Rows.Add(1); + var reader = table.CreateDataReader(); + command.Reader = reader; + var rows = new MssqlRowReader(reader, owner); + rows.Dispose(); rows.Dispose(); + Assert(events.Count == 1 && events[0] == "command", "Command disposed once after reader, cancel registration removed before command disposal"); + Assert(reader.IsClosed, "Row reader closes underlying reader"); + using (var cancel = new System.Threading.CancellationTokenSource()) + { + var live = new MetadataCommand(events, null); + using (var owned = new MssqlCommandOwner(live, cancel.Token)) cancel.Cancel(); + Assert(events.Contains("cancel"), "Owned command receives cancellation"); + } + cancellation.Dispose(); + } + private sealed class MetadataCommand : System.Data.Common.DbCommand + { + private readonly List events; + private readonly System.Threading.CancellationTokenSource cancellation; + public System.Data.Common.DbDataReader Reader; + public MetadataCommand(List events, System.Threading.CancellationTokenSource cancellation) { this.events = events; this.cancellation = cancellation; } + public override void Cancel() { events.Add("cancel"); } + protected override void Dispose(bool disposing) { if (disposing) { Assert(Reader == null || Reader.IsClosed, "Reader must close before command"); cancellation?.Cancel(); events.Add("command"); } base.Dispose(disposing); } + public override string CommandText { get; set; } + public override int CommandTimeout { get; set; } + public override CommandType CommandType { get; set; } + public override bool DesignTimeVisible { get; set; } + public override UpdateRowSource UpdatedRowSource { get; set; } + protected override System.Data.Common.DbConnection DbConnection { get; set; } + protected override System.Data.Common.DbTransaction DbTransaction { get; set; } + protected override System.Data.Common.DbParameterCollection DbParameterCollection => throw new NotSupportedException(); + public override int ExecuteNonQuery() { throw new NotSupportedException(); } + public override object ExecuteScalar() { throw new NotSupportedException(); } + public override void Prepare() { throw new NotSupportedException(); } + protected override System.Data.Common.DbParameter CreateDbParameter() { throw new NotSupportedException(); } + protected override System.Data.Common.DbDataReader ExecuteDbDataReader(CommandBehavior behavior) { throw new NotSupportedException(); } + } + private static void Session_DisposesOwnerOnce() { var owner = new MssqlSessionOwner(); diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 1494ede..c006a58 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -28,6 +28,9 @@ public static partial class SmokeTests public static int Main() { int passed = 0; + Run("MSSQL export metadata mapping and validation", Metadata_MapsNativeFactsAndValidates, ref passed); + Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); + Run("MSSQL export metadata native reader", Metadata_RequiresNativeReader, ref passed); Run("MSSQL export session owner", Session_DisposesOwnerOnce, ref passed); Run("MSSQL export session request", Request_CopiesSelectionAndRejectsInvalidMode, ref passed); Run("Geometry WKB 轉 WKT", TestGeometryWktConverter, ref passed); From b91e2aae53a3111094b674575418e92139ce7776 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:23:45 +0800 Subject: [PATCH 05/32] fix(mssql-export): preserve cancellation during command startup --- .../MssqlExport/MssqlExportMetadataReader.cs | 5 ++-- .../lib/MssqlExport/MssqlExportSource.cs | 11 +++++++- tests/SmokeTests.MssqlExport.cs | 26 ++++++++++++++++++- tests/SmokeTests.cs | 1 + 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs index 4208a7e..25ab845 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs @@ -175,9 +175,8 @@ internal static List> Query(SqlConnection connection, { token.ThrowIfCancellationRequested(); var result = new List>(); - using (var command = new SqlCommand(sql, connection, transaction) { CommandTimeout = 0 }) - using (token.Register(() => { try { command.Cancel(); } catch (ObjectDisposedException) { } })) - using (var reader = command.ExecuteReader()) + using (var owner = new MssqlCommandOwner(new SqlCommand(sql, connection, transaction) { CommandTimeout = 0 }, token)) + using (var reader = owner.ExecuteReader(CommandBehavior.Default)) { while (reader.Read()) { token.ThrowIfCancellationRequested(); var row = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < reader.FieldCount; i++) row.Add(reader.GetName(i), reader.IsDBNull(i) ? null : reader.GetValue(i)); result.Add(row); } diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs index 50edb85..5a87d48 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs @@ -77,7 +77,7 @@ public MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken can string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(c => MssqlExportMetadataReader.Quote(c.Name))); var command = new SqlCommand("SELECT " + projection + " FROM " + MssqlExportMetadataReader.Qualified(table.Key), session.Connection, transaction) { CommandTimeout = 0 }; var owner = new MssqlCommandOwner(command, cancellation); - try { activeReader = new MssqlRowReader(command.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } + try { activeReader = new MssqlRowReader(owner.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } catch { owner.Dispose(); throw; } } catch { Dispose(); throw; } @@ -117,11 +117,20 @@ internal sealed class MssqlCommandOwner : IDisposable { private DbCommand command; private CancellationTokenRegistration registration; + private readonly CancellationToken cancellation; internal MssqlCommandOwner(DbCommand command, CancellationToken cancellation) { this.command = command; + this.cancellation = cancellation; registration = cancellation.Register(() => { try { command.Cancel(); } catch (ObjectDisposedException) { } }); } + internal DbDataReader ExecuteReader(CommandBehavior behavior) + { + // The provider must observe cancellation during startup, not just receive + // an idle Cancel() before synchronous execution starts. Keep our separate + // registration alive for subsequent synchronous reads of the returned reader. + return command.ExecuteReaderAsync(behavior, cancellation).GetAwaiter().GetResult(); + } public void Dispose() { var value = command; diff --git a/tests/SmokeTests.MssqlExport.cs b/tests/SmokeTests.MssqlExport.cs index 1df5a61..d67a1b4 100644 --- a/tests/SmokeTests.MssqlExport.cs +++ b/tests/SmokeTests.MssqlExport.cs @@ -49,6 +49,21 @@ private static void Metadata_MapsNativeFactsAndValidates() Assert(schema.Fingerprint == estimated.Fingerprint, "Changing row estimate is not schema drift"); } + private static void Metadata_CancelsBeforeAndDuringReaderStartup() + { + foreach (bool cancelBefore in new[] { true, false }) + { + using (var cancellation = new System.Threading.CancellationTokenSource()) + { + var command = new MetadataCommand(new List(), null); + command.CancelAtStartup = cancelBefore ? null : cancellation; + if (cancelBefore) cancellation.Cancel(); + using (var owner = new MssqlCommandOwner(command, cancellation.Token)) + AssertThrows(() => owner.ExecuteReader(CommandBehavior.SequentialAccess), "Startup cancellation must reach the provider even after idle Cancel was consumed"); + } + } + } + private static void Metadata_DisposesReaderAndCancellationBeforeCommand() { var events = new List(); @@ -75,6 +90,7 @@ private sealed class MetadataCommand : System.Data.Common.DbCommand private readonly List events; private readonly System.Threading.CancellationTokenSource cancellation; public System.Data.Common.DbDataReader Reader; + public System.Threading.CancellationTokenSource CancelAtStartup; public MetadataCommand(List events, System.Threading.CancellationTokenSource cancellation) { this.events = events; this.cancellation = cancellation; } public override void Cancel() { events.Add("cancel"); } protected override void Dispose(bool disposing) { if (disposing) { Assert(Reader == null || Reader.IsClosed, "Reader must close before command"); cancellation?.Cancel(); events.Add("command"); } base.Dispose(disposing); } @@ -90,7 +106,15 @@ private sealed class MetadataCommand : System.Data.Common.DbCommand public override object ExecuteScalar() { throw new NotSupportedException(); } public override void Prepare() { throw new NotSupportedException(); } protected override System.Data.Common.DbParameter CreateDbParameter() { throw new NotSupportedException(); } - protected override System.Data.Common.DbDataReader ExecuteDbDataReader(CommandBehavior behavior) { throw new NotSupportedException(); } + protected override System.Data.Common.DbDataReader ExecuteDbDataReader(CommandBehavior behavior) { return new DataTable().CreateDataReader(); } + protected override System.Threading.Tasks.Task ExecuteDbDataReaderAsync(CommandBehavior behavior, System.Threading.CancellationToken token) + { + // Cancel() while idle intentionally has no effect on a later execution. + // A provider that receives the token can reject cancellation during startup. + CancelAtStartup?.Cancel(); + token.ThrowIfCancellationRequested(); + return System.Threading.Tasks.Task.FromResult(new DataTable().CreateDataReader()); + } } private static void Session_DisposesOwnerOnce() diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index c006a58..ba6fcdb 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -29,6 +29,7 @@ public static int Main() { int passed = 0; Run("MSSQL export metadata mapping and validation", Metadata_MapsNativeFactsAndValidates, ref passed); + Run("MSSQL export metadata startup cancellation", Metadata_CancelsBeforeAndDuringReaderStartup, ref passed); Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); Run("MSSQL export metadata native reader", Metadata_RequiresNativeReader, ref passed); Run("MSSQL export session owner", Session_DisposesOwnerOnce, ref passed); From fce1f2959e3f7505277854e09207f330c3450f99 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:26:07 +0800 Subject: [PATCH 06/32] docs(mssql-export): record metadata verification and identity contract --- docs/design/mssql-export/implementation-plan.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index 14cf0d4..f24457d 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -165,16 +165,16 @@ internal MssqlExportSchema ReadSchema(SqlConnection connection, SqlTransaction t internal IReadOnlyList Validate(MssqlExportSchema schema, MssqlExportRequest request); ``` -- [ ] **1. 寫失敗測試,group `metadata`。** 覆蓋同名不同 schema/名稱含 `]`、spatial 明列、依賴缺漏、snapshot OFF/過渡狀態、estimate 缺失不是 0、typed XML/UDT 拒絕;assert: +- [x] **1. 寫失敗測試,group `metadata`。** 覆蓋同名不同 schema/名稱含 `]`、spatial 明列、依賴缺漏、snapshot OFF/過渡狀態、estimate 缺失不是 0、typed XML/UDT 拒絕;assert: ```csharp Assert(catalog.Tables.Count == 2, "dbo.orders and sales.orders are distinct"); Assert(catalog.Tables[0].EstimatedRows == null, "Unavailable estimate is unknown"); Assert(issues.Any(x => x.Code == "UnsupportedSpatial" && x.ColumnName == "shape"), "Name rejected column"); ``` -- [ ] **2. 跑 metadata group 為紅。** metadata mapping 以 fixture rows 測純轉換;SQL查詢本身的證據留 Task 10,不能把 fake 當 live query 驗證。 -- [ ] **3. 實作版本/能力感知 sys queries。** metadata 需涵蓋 tables/columns/types/schemas、extended properties、identity/computed/default、rowstore indexes/index_columns、FK/check、view definition/SET/dependencies。用可用 catalog 欄位/版本選查詢,舊版本缺少 ledger/temporal 欄位時不查不存在欄位。看不到 definition 或權限錯誤明確報錯,不以空集合替代。針對 spec 特殊表/型別/索引完整列出阻擋原因。 -- [ ] **4. 實作 Source transaction/reader。** 改變 owned connection 的 database;Inspect以無transaction的ReadSchema供確認頁預檢,完畢dispose source,不在使用者思考時持有快照。含資料開始時再確認 snapshot ON,BeginRead 開一個 Snapshot transaction 並重新凍結/驗證 schema;SchemaOnly 不開資料快照。OpenRows 明確欄位、無排序/OFFSET需求、SequentialAccess;command timeout 直接設為 0 並登記 Cancel,dispose 登記先於 command。CompleteRead 核對 object IDs/完整 definition fingerprint,再結束交易;例外/取消 rollback 並 dispose。schema改變中途不得 retry 續跑。 -- [ ] **5. 跑 metadata group 為綠並提交。** 加 reader/command/dispose 次序 fake 驗證;實際 snapshot/DDL race 在 Task 10。建議訊息 `feat(mssql-export): collect native metadata and preflight snapshot export`。 +- [x] **2. 跑 metadata group 為紅。** metadata mapping 以 fixture rows 測純轉換;SQL查詢本身的證據留 Task 10,不能把 fake 當 live query 驗證。 +- [x] **3. 實作版本/能力感知 sys queries。** metadata 需涵蓋 tables/columns/types/schemas、extended properties、identity/computed/default、rowstore indexes/index_columns、FK/check、view definition/SET/dependencies。用可用 catalog 欄位/版本選查詢,舊版本缺少 ledger/temporal 欄位時不查不存在欄位。看不到 definition 或權限錯誤明確報錯,不以空集合替代。針對 spec 特殊表/型別/索引完整列出阻擋原因。 +- [x] **4. 實作 Source transaction/reader。** 改變 owned connection 的 database;Inspect以無transaction的ReadSchema供確認頁預檢,完畢dispose source,不在使用者思考時持有快照。含資料開始時再確認 snapshot ON,BeginRead 開一個 Snapshot transaction 並重新凍結/驗證 schema;SchemaOnly 不開資料快照。OpenRows 明確欄位、無排序/OFFSET需求、SequentialAccess;command timeout 直接設為 0 並登記 Cancel,dispose 登記先於 command。CompleteRead 核對 object IDs/完整 definition fingerprint,再結束交易;例外/取消 rollback 並 dispose。schema改變中途不得 retry 續跑。 +- [x] **5. 跑 metadata group 為綠並提交。** 加 reader/command/dispose 次序 fake 驗證;實際 snapshot/DDL race 在 Task 10。建議訊息 `feat(mssql-export): collect native metadata and preflight snapshot export`。 ## Task 3:原生結構腳本與依賴順序 @@ -196,7 +196,7 @@ internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest reques ``` - [ ] **2. 跑 schema group 為紅。** 加 circular FK、未包含 view/function/sequence/trigger、不可見 definition 的失敗案例;不能生成成功卻遺漏物件。 - [ ] **3. 實作前後兩階段 writer。** 前段 schema/table、原生欄位與 default/computed;後段 keys/indexes、FK/CHECK、comments、依賴排序 views。自參照/循环 FK 留資料後建立。資料來源不含 computed/rowversion 值,摘要說明 rowversion 重生。僅資料兩段皆不建立物件;僅結構仍產生完整兩段。型別/identifier 不經 DatabaseDumpService mapper。 -- [ ] **4. 明定 restore 語意與 identity 處理。** 結構模式預期空白/無衝突目標,存在物件應明確失敗,不產生 DROP DATABASE/TABLE;DataOnly 要求相容既有表。identity 的 seed/increment/last value 與空表、負 increment 情況須能供 Task 4 資料段及 Task 10 後續新增驗證,避免「已插最大值」被誤當全部規則。 +- [ ] **4. 明定 restore 語意與 identity 處理。** 結構模式預期空白/無衝突目標,存在物件應明確失敗,不產生 DROP DATABASE/TABLE;DataOnly 要求相容既有表。identity 的 seed/increment 與匯入值,以及空表、負 increment 的後續新增須能供 Task 4 資料段及 Task 10 驗證;依設計不承諾來源配號間隙或完整 last_value 歷史,避免「已插最大值」被誤當全部規則。 - [ ] **5. 跑 schema group 為綠並提交。** SQL tokens 測試不是還原證據;native execution 留 Task 10。建議訊息 `feat(mssql-export): script native schema and ordered dependencies`。 ## Task 4:一般列串流、型別及批次預算 From 4e1ec67c771283fcedaf19c40b40fba578e9dbde Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:17:33 +0800 Subject: [PATCH 07/32] feat(mssql-export): verify and publish exports and mirrors safely --- .../lib/MssqlExport/MssqlExportArtifact.cs | 203 ++++++++++++++++ .../lib/MssqlExport/MssqlExportMirror.cs | 110 +++++++++ .../lib/MssqlExport/MssqlExportModels.cs | 28 +++ mySQLPunk/mySQLPunk.csproj | 2 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Artifact.cs | 222 ++++++++++++++++++ tests/SmokeTests.cs | 3 + 7 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportMirror.cs create mode 100644 tests/SmokeTests.MssqlExport.Artifact.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs b/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs new file mode 100644 index 0000000..cafdb8b --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace mySQLPunk.lib.MssqlExport +{ + // These gates coordinate this process only, not external writers or path aliases. + internal static class MssqlArtifactGates + { + private static readonly ConcurrentDictionary Gates = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + internal static SemaphoreSlim Get(string path) => Gates.GetOrAdd(Path.GetFullPath(path).TrimEnd('\\', '/'), _ => new SemaphoreSlim(1, 1)); + } + internal class MssqlArtifactFiles + { + internal virtual Stream Create(string path) => new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920); + internal virtual void Move(string source, string target) => File.Move(source, target); + internal virtual void Replace(string source, string target, string recovery) => File.Replace(source, target, recovery, false); + internal virtual void Delete(string path) => File.Delete(path); + internal virtual void Prune(string directory, int count) => BackupRemoteMirrorService.PruneRemoteBackups(directory, count); + } + // Cancellation and entry to the short commit region have a single lock order. + // A request arriving after entry cannot turn a successful publication into "nothing output". + internal sealed class MssqlPublicationCommit : IDisposable + { + private readonly object sync = new object(); + private readonly CancellationToken token; + private readonly CancellationTokenRegistration registration; + private bool canceled, entered; + internal MssqlPublicationCommit(CancellationToken token) + { + this.token = token; + registration = token.Register(() => { lock (sync) { if (!entered) canceled = true; } }); + } + internal void Enter() { lock (sync) { if (canceled || token.IsCancellationRequested) throw new OperationCanceledException(token); entered = true; } } + public void Dispose() => registration.Dispose(); + } + internal sealed class MssqlExportArtifact : IDisposable + { + internal const int BufferSize = 81920; + private readonly MssqlExportRequest request; + private readonly MssqlArtifactFiles files; + private readonly HashingOutput output; + private readonly List residuals = new List(); + private readonly List warnings = new List(); + private MssqlArtifactReceipt receipt; + private bool verified, preserveCandidates, disposed; + internal string TemporaryPath { get; } + internal string RecoveryPath { get; private set; } + internal string PublishedPath { get; private set; } + internal IReadOnlyList ResidualPaths => new List(residuals).AsReadOnly(); + internal IReadOnlyList Warnings => new List(warnings).AsReadOnly(); + internal Stream Output => output; + private MssqlExportArtifact(MssqlExportRequest request, MssqlArtifactFiles files) + { + this.request = request ?? throw new ArgumentNullException(nameof(request)); this.files = files; + TemporaryPath = Path.Combine(Path.GetDirectoryName(request.TargetPath), ".mysqlpunk-export-" + request.JobId.ToString("N") + "-" + Guid.NewGuid().ToString("N") + ".partial"); + output = new HashingOutput(files.Create(TemporaryPath)); + } + internal static MssqlExportArtifact Create(MssqlExportRequest request) => Create(request, new MssqlArtifactFiles()); + internal static MssqlExportArtifact Create(MssqlExportRequest request, MssqlArtifactFiles files) => new MssqlExportArtifact(request, files); + internal static ExportDestinationStamp CaptureDestination(string path, CancellationToken cancellation) + { + string full = Path.GetFullPath(path); var gate = MssqlArtifactGates.Get(full); gate.Wait(cancellation); + try { return CaptureCore(full, cancellation); } finally { gate.Release(); } + } + private static ExportDestinationStamp CaptureCore(string full, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + FileStream stream; + try { stream = new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize); } + catch (FileNotFoundException) { return new ExportDestinationStamp(false, full, null, 0, DateTime.MinValue, null); } + using (stream) + { + FileInformation info; + if (!GetFileInformationByHandle(stream.SafeFileHandle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + string identity = info.Volume.ToString("X8") + ":" + info.IndexHigh.ToString("X8") + info.IndexLow.ToString("X8"); + long length; string hash = Hash(stream, null, cancellation, out length); + long timestamp = ((long)info.WriteHigh << 32) | info.WriteLow; + return new ExportDestinationStamp(true, full, identity, length, DateTime.FromFileTimeUtc(timestamp), hash); + } + } + internal MssqlArtifactReceipt Complete(IReadOnlyList rows, CancellationToken cancellation) + { + if (disposed || receipt != null) throw new InvalidOperationException("Artifact is already closed"); + if (rows == null) throw new ArgumentNullException(nameof(rows)); + long total = 0; foreach (var row in rows) { if (row == null) throw new ArgumentException("Null row count"); total = checked(total + row.Rows); } + cancellation.ThrowIfCancellationRequested(); + string marker = "\r\n-- mysqlpunk-export-complete job=" + request.JobId.ToString("N") + " tables=" + rows.Count.ToString(CultureInfo.InvariantCulture) + " rows=" + total.ToString(CultureInfo.InvariantCulture) + "\r\n"; + byte[] bytes = new UTF8Encoding(false, true).GetBytes(marker); output.Write(bytes, 0, bytes.Length); + output.Finish(); + receipt = new MssqlArtifactReceipt(request.JobId, TemporaryPath, request.TargetPath, output.Bytes, output.Digest, rows, marker); + return receipt; + } + internal void Verify(MssqlArtifactReceipt candidate, Action progress, CancellationToken cancellation) + { + verified = false; + if (disposed || receipt == null || !ReferenceEquals(candidate, receipt)) throw new InvalidOperationException("Complete this artifact before verifying"); + VerifyFile(TemporaryPath, candidate, progress, cancellation); verified = true; + } + internal static void VerifyFile(string path, MssqlArtifactReceipt expected, Action progress, CancellationToken cancellation) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize)) + { + long length; string hash = Hash(stream, progress, cancellation, out length); + if (length != expected.Bytes || !string.Equals(hash, expected.Sha256, StringComparison.Ordinal)) throw new InvalidDataException("Export length or SHA-256 mismatch"); + } + } + internal static string Hash(Stream stream, Action progress, CancellationToken cancellation, out long length) + { + byte[] buffer = new byte[BufferSize]; length = 0; long total = stream.Length; + using (var hash = SHA256.Create()) + { + int read; + while (true) + { + cancellation.ThrowIfCancellationRequested(); read = stream.Read(buffer, 0, buffer.Length); if (read == 0) break; + hash.TransformBlock(buffer, 0, read, null, 0); length = checked(length + read); progress?.Invoke(length, total); + } + cancellation.ThrowIfCancellationRequested(); hash.TransformFinalBlock(buffer, 0, 0); return BitConverter.ToString(hash.Hash).Replace("-", ""); + } + } + internal string Publish(ExportDestinationStamp expectedDestination, CancellationToken cancellation) + { + if (disposed || !verified || PublishedPath != null) throw new InvalidOperationException("Artifact must be verified and unpublished"); + if (expectedDestination == null || !string.Equals(expectedDestination.FullPath, request.TargetPath, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Consent stamp does not match destination"); + var gate = MssqlArtifactGates.Get(request.TargetPath); gate.Wait(cancellation); + try + { + VerifyFile(TemporaryPath, receipt, null, cancellation); + var actual = CaptureCore(request.TargetPath, cancellation); + if (actual.Exists != expectedDestination.Exists || actual.FileIdentity != expectedDestination.FileIdentity || actual.Length != expectedDestination.Length || actual.LastWriteTimeUtc != expectedDestination.LastWriteTimeUtc || actual.Sha256 != expectedDestination.Sha256) throw new IOException("Destination changed after overwrite consent"); + using (var commit = new MssqlPublicationCommit(cancellation)) + { + commit.Enter(); + if (actual.Exists) + { + RecoveryPath = Path.Combine(Path.GetDirectoryName(request.TargetPath), ".mysqlpunk-recovery-" + Guid.NewGuid().ToString("N") + ".recovery"); + try { files.Replace(TemporaryPath, request.TargetPath, RecoveryPath); } + catch { preserveCandidates = true; ObserveCandidate(TemporaryPath); ObserveCandidate(RecoveryPath); ObserveCandidate(request.TargetPath); throw; } + } + else files.Move(TemporaryPath, request.TargetPath); + PublishedPath = request.TargetPath; + } + // A successful replace retains the old version for explicit recovery, not retention pruning. + if (RecoveryPath != null) ObserveCandidate(RecoveryPath); + return PublishedPath; + } + finally { gate.Release(); } + } + private void ObserveCandidate(string path) + { + try { File.GetAttributes(path); if (!residuals.Contains(path)) residuals.Add(path); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (Exception ex) { if (!residuals.Contains(path)) residuals.Add(path); warnings.Add("Candidate state unknown: " + path + ": " + ex.Message); } + } + public void Dispose() + { + if (disposed) return; disposed = true; + try { output.Dispose(); } catch (Exception ex) { warnings.Add("Close failed: " + ex.Message); } + if (PublishedPath != null || preserveCandidates) return; + try { files.Delete(TemporaryPath); } catch (Exception ex) { residuals.Add(TemporaryPath); warnings.Add("Temporary cleanup failed: " + ex.Message); } + } + [StructLayout(LayoutKind.Sequential)] private struct FileInformation + { + internal uint Attributes, CreateLow, CreateHigh, AccessLow, AccessHigh, WriteLow, WriteHigh, Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation info); + private sealed class HashingOutput : Stream + { + private readonly Stream inner; private readonly SHA256 hash = SHA256.Create(); private bool closed, faulted; + internal long Bytes { get; private set; } internal string Digest { get; private set; } + internal HashingOutput(Stream inner) { this.inner = inner; } + public override void Write(byte[] buffer, int offset, int count) + { + if (closed || faulted) throw new InvalidOperationException("Output is closed or faulted"); + try { inner.Write(buffer, offset, count); hash.TransformBlock(buffer, offset, count, null, 0); Bytes = checked(Bytes + count); } + catch { faulted = true; throw; } + } + internal void Finish() + { + if (closed || faulted) throw new InvalidOperationException("Cannot complete faulted output"); + try { inner.Flush(); inner.Dispose(); closed = true; hash.TransformFinalBlock(new byte[0], 0, 0); Digest = BitConverter.ToString(hash.Hash).Replace("-", ""); } + catch { faulted = true; throw; } + } + public override void Flush() { try { inner.Flush(); } catch { faulted = true; throw; } } + protected override void Dispose(bool disposing) { if (disposing) { try { if (!closed) inner.Dispose(); } finally { closed = true; hash.Dispose(); } } base.Dispose(disposing); } + public override bool CanRead => false; public override bool CanSeek => false; public override bool CanWrite => !closed && !faulted; + public override long Length => Bytes; public override long Position { get => Bytes; set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMirror.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMirror.cs new file mode 100644 index 0000000..88e4495 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMirror.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + internal sealed class MssqlExportMirror + { + private readonly MssqlArtifactFiles files; + private readonly List residuals = new List(); + private readonly List warnings = new List(); + internal string MirrorPublishedPath { get; private set; } + internal IReadOnlyList ResidualPaths => new List(residuals).AsReadOnly(); + internal IReadOnlyList Warnings => new List(warnings).AsReadOnly(); + internal MssqlExportMirror() : this(new MssqlArtifactFiles()) { } + internal MssqlExportMirror(MssqlArtifactFiles files) { this.files = files; } + internal string CopyVerified(string publishedPath, MssqlArtifactReceipt receipt, MssqlMirrorOptions options, Action progress, CancellationToken cancellation) + { + if (MirrorPublishedPath != null) throw new InvalidOperationException("Mirror already published"); + if (receipt == null || options == null) throw new ArgumentNullException(); + string source = Path.GetFullPath(publishedPath); + string directory = options.Directory; + if (directory.Length > Path.GetPathRoot(directory).Length) directory = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + cancellation.ThrowIfCancellationRequested(); + if (string.Equals(Path.GetDirectoryName(source), directory, StringComparison.OrdinalIgnoreCase)) return string.Empty; + Directory.CreateDirectory(directory); + string temporary = Path.Combine(directory, ".mysqlpunk-export-" + receipt.JobId.ToString("N") + "-" + Guid.NewGuid().ToString("N") + ".partial"); + bool owned = false; + try + { + using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, MssqlExportArtifact.BufferSize)) + using (var output = files.Create(temporary)) + { + owned = true; + byte[] buffer = new byte[MssqlExportArtifact.BufferSize]; int read; long copied = 0; + while (true) + { + cancellation.ThrowIfCancellationRequested(); read = input.Read(buffer, 0, buffer.Length); if (read == 0) break; + output.Write(buffer, 0, read); copied = checked(copied + read); progress?.Invoke(copied, receipt.Bytes); + } + output.Flush(); + } + MssqlExportArtifact.VerifyFile(temporary, receipt, progress, cancellation); + var gate = MssqlArtifactGates.Get(directory); gate.Wait(cancellation); + try + { + string target = Path.Combine(directory, Path.GetFileNameWithoutExtension(source) + "_" + receipt.JobId.ToString("N") + "_" + Guid.NewGuid().ToString("N") + Path.GetExtension(source)); + File.SetLastWriteTimeUtc(temporary, DateTime.UtcNow); + using (var commit = new MssqlPublicationCommit(cancellation)) + { + commit.Enter(); files.Move(temporary, target); MirrorPublishedPath = target; owned = false; + } + if (cancellation.IsCancellationRequested) warnings.Add("Mirror published; retention skipped after cancellation"); + else + { + try + { + // Future-dated backups must not make the just-published file the prune victim. + if (WouldPrunePublished(target, options.RetainCount)) warnings.Add("Mirror published; retention skipped because newer timestamps would remove it"); + else + { + files.Prune(directory, options.RetainCount); + if (RetentionExceeded(directory, options.RetainCount)) warnings.Add("Mirror published; some managed backups exceed the retention limit"); + } + } + catch (Exception ex) { warnings.Add("Mirror published; retention incomplete: " + ex.Message); } + } + return MirrorPublishedPath; + } + finally { gate.Release(); } + } + finally + { + if (owned) + { + try { files.Delete(temporary); } + catch (Exception ex) { residuals.Add(temporary); warnings.Add("Mirror temporary cleanup failed: " + ex.Message); } + } + } + } + // Matches BackupRemoteMirrorService's existing classification and precedence. + private static string Kind(string name) + { + string lower = name.ToLowerInvariant(); + if (lower.Contains("_before_delete_")) return "before_delete"; + if (lower.Contains("_before_restore_")) return "before_restore"; + if (lower.Contains("_backup_")) return "backup"; + return string.Empty; + } + private static bool WouldPrunePublished(string path, int retain) + { + string kind = Kind(Path.GetFileName(path)); if (kind.Length == 0) return false; + DateTime stamp = File.GetLastWriteTimeUtc(path); int newerOrEqual = 0; + foreach (var file in new DirectoryInfo(Path.GetDirectoryName(path)).GetFiles()) + if (!string.Equals(file.FullName, path, StringComparison.OrdinalIgnoreCase) && Kind(file.Name) == kind && file.LastWriteTimeUtc >= stamp) newerOrEqual++; + return newerOrEqual >= retain; + } + private static bool RetentionExceeded(string directory, int retain) + { + var counts = new Dictionary(); + foreach (var file in new DirectoryInfo(directory).GetFiles()) + { + string kind = Kind(file.Name); if (kind.Length == 0) continue; + int count; counts.TryGetValue(kind, out count); counts[kind] = ++count; if (count > retain) return true; + } + return false; + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs index 1bb0d59..70a646f 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -228,5 +228,33 @@ internal sealed class MssqlExportPreflight public bool CanStart => Issues.Count == 0; internal MssqlExportPreflight(MssqlExportSchema schema, IEnumerable issues) { Schema = schema; Issues = MssqlNativeFacts.Copy(issues); } } + internal sealed class MssqlTableRowCount + { + public MssqlObjectKey Key { get; } + public long Rows { get; } + public MssqlTableRowCount(MssqlObjectKey key, long rows) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (rows < 0) throw new ArgumentOutOfRangeException(nameof(rows)); + Key = key; Rows = rows; + } + } + internal sealed class MssqlArtifactReceipt + { + public Guid JobId { get; } + public string TemporaryPath { get; } + public string IntendedTargetPath { get; } + public long Bytes { get; } + public string Sha256 { get; } + public IReadOnlyList Tables { get; } + public long TotalRows { get; } + public string CompletionMarker { get; } + internal MssqlArtifactReceipt(Guid jobId, string temporaryPath, string publishedPath, long bytes, string sha256, IReadOnlyList tables, string marker) + { + JobId = jobId; TemporaryPath = temporaryPath; IntendedTargetPath = publishedPath; Bytes = bytes; Sha256 = sha256; + Tables = new List(tables).AsReadOnly(); CompletionMarker = marker; + long total = 0; foreach (var table in Tables) total = checked(total + table.Rows); TotalRows = total; + } + } } diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 8a02726..4555a7b 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -360,6 +360,8 @@ + + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index b112356..97190b7 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Artifact.cs b/tests/SmokeTests.MssqlExport.Artifact.cs new file mode 100644 index 0000000..7e701ba --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Artifact.cs @@ -0,0 +1,222 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using mySQLPunk.lib.MssqlExport; +public static partial class SmokeTests +{ + // Removing hash checks, commit ordering or ownership cleanup must break these cases. + private static void Artifact_Pipeline() + { + string root = Path.Combine(Path.GetTempPath(), "mssql-artifact-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + string target = Path.Combine(root, "fixture_backup_current.sql"); + File.WriteAllText(target, "old fixture"); + string other = Path.Combine(root, ".mysqlpunk-export-other.partial"); + File.WriteAllText(other, "other"); + var stamp = MssqlExportArtifact.CaptureDestination(target, CancellationToken.None); + var request = ArtifactRequest(target, stamp); + using (var artifact = MssqlExportArtifact.Create(request)) + { + var receipt = FinishArtifact(artifact); + AssertThrows(() => artifact.Publish(stamp, CancellationToken.None), "Unverified output cannot publish"); + using (var truncate = new FileStream(artifact.TemporaryPath, FileMode.Open, FileAccess.Write)) truncate.SetLength(receipt.Bytes - 1); + AssertThrows(() => artifact.Verify(receipt, null, CancellationToken.None), "Truncated output fails verification"); + } + Assert(File.ReadAllText(target) == "old fixture" && File.Exists(other), "Failure preserves old and unrelated partial"); + using (var artifact = MssqlExportArtifact.Create(request)) + { + var receipt = FinishArtifact(artifact); + artifact.Verify(receipt, null, CancellationToken.None); + var time = File.GetLastWriteTimeUtc(target); + File.WriteAllText(target, "new fixture"); File.SetLastWriteTimeUtc(target, time); + AssertThrows(() => artifact.Publish(stamp, CancellationToken.None), "Same size/time changes fail consent recheck"); + } + stamp = MssqlExportArtifact.CaptureDestination(target, CancellationToken.None); + request = ArtifactRequest(target, stamp); + using (var artifact = MssqlExportArtifact.Create(request)) + { + var receipt = FinishArtifact(artifact); + long last = 0; + artifact.Verify(receipt, (done, total) => { Assert(done - last <= 81920, "Verification bounded read"); last = done; }, CancellationToken.None); + Assert(last == receipt.Bytes, "Entire final marker is verified"); + var canceled = new CancellationTokenSource(); canceled.Cancel(); + AssertThrows(() => artifact.Publish(stamp, canceled.Token), "Cancel before commit"); + Assert(artifact.Publish(stamp, CancellationToken.None) == target, "Published path retained"); + canceled.Cancel(); Assert(artifact.PublishedPath == target, "Late cancel does not erase publication"); + var mirror = new MssqlExportMirror(); + Assert(mirror.CopyVerified(target, receipt, new MssqlMirrorOptions(root, 1), null, CancellationToken.None) == string.Empty, "Same directory is noop"); + string remote = Path.Combine(root, "remote"); Directory.CreateDirectory(remote); + string old = Path.Combine(remote, "old_backup_fixture.sql"); File.WriteAllText(old, "old fixture"); File.SetLastWriteTimeUtc(old, DateTime.UtcNow.AddDays(-1)); + string mirrorPath = mirror.CopyVerified(target, receipt, new MssqlMirrorOptions(remote, 1), null, CancellationToken.None); + Assert(File.Exists(mirrorPath) && !File.Exists(old), "Verified mirror published before retention"); + var stampMirror = MssqlExportArtifact.CaptureDestination(mirrorPath, CancellationToken.None); + Assert(stampMirror.Sha256 == receipt.Sha256 && stampMirror.Length == receipt.Bytes, "Mirror matches final receipt"); + AssertThrows(() => new MssqlExportMirror().CopyVerified(target, receipt, new MssqlMirrorOptions(remote, 1), null, canceled.Token), "Canceled mirror must not prune"); + Assert(File.Exists(mirrorPath), "Canceled mirror preserves old backups"); + } + string corrupt = Path.Combine(root,"postverify.sql"); + var absent = MssqlExportArtifact.CaptureDestination(corrupt,CancellationToken.None); + using(var artifact=MssqlExportArtifact.Create(ArtifactRequest(corrupt,absent))) + { + var receipt=FinishArtifact(artifact);artifact.Verify(receipt,null,CancellationToken.None); + using(var tamper=new FileStream(artifact.TemporaryPath,FileMode.Open,FileAccess.Write)) tamper.WriteByte(0); + AssertThrows(()=>artifact.Publish(absent,CancellationToken.None),"Mutation after verification cannot publish corrupt output"); + Assert(!File.Exists(corrupt),"Invalid output leaves absent destination absent"); + } + ArtifactFailures(root); + } + finally { Directory.Delete(root, true); } + } + private static MssqlExportRequest ArtifactRequest(string path, ExportDestinationStamp stamp) + { + return new MssqlExportRequest(Guid.NewGuid(), "fixture", new[] {new MssqlObjectKey(1,"dbo","T")}, MssqlExportMode.SchemaAndData, false, path, stamp, null); + } + private static MssqlArtifactReceipt FinishArtifact(MssqlExportArtifact artifact) + { + byte[] bytes = Encoding.UTF8.GetBytes("-- fixture\r\n" + new string('x', 180000)); + artifact.Output.Write(bytes, 0, bytes.Length); + return artifact.Complete(new[] {new MssqlTableRowCount(new MssqlObjectKey(1,"dbo","T"), 3000000000L)}, CancellationToken.None); + } + private static void ArtifactFailures(string root) + { + for (int mode = 0; mode < 3; mode++) + { + string path = Path.Combine(root, "replace" + mode + ".sql"); File.WriteAllText(path, "old fixture"); + var stamp = MssqlExportArtifact.CaptureDestination(path, CancellationToken.None); + var ops = new ArtifactFaultFiles { ReplaceFailure = mode + 1 }; + var artifact = MssqlExportArtifact.Create(ArtifactRequest(path, stamp), ops); + var receipt = FinishArtifact(artifact); artifact.Verify(receipt, null, CancellationToken.None); + AssertThrows(() => artifact.Publish(stamp, CancellationToken.None), "Documented partial replace failure"); + artifact.Dispose(); + Assert(artifact.ResidualPaths.Count > 0, "Ambiguous candidates reported and preserved"); + string oldPath = mode == 2 ? artifact.RecoveryPath : path; + Assert(File.ReadAllText(oldPath) == "old fixture", "Old bytes recoverable after replacement failure"); + } + string cleanupPath = Path.Combine(root, "cleanup.sql"); + var cleanup = MssqlExportArtifact.Create(ArtifactRequest(cleanupPath, MssqlExportArtifact.CaptureDestination(cleanupPath, CancellationToken.None)), new ArtifactFaultFiles { FailDelete = true }); + cleanup.Dispose(); Assert(cleanup.ResidualPaths.Count == 1 && File.Exists(cleanup.TemporaryPath), "Failed cleanup reports owned residual"); + string local = Path.Combine(root,"mirror-source.sql"); + using(var artifact = MssqlExportArtifact.Create(ArtifactRequest(local, MssqlExportArtifact.CaptureDestination(local,CancellationToken.None)))) + { + var receipt=FinishArtifact(artifact); artifact.Verify(receipt,null,CancellationToken.None); artifact.Publish(MssqlExportArtifact.CaptureDestination(local,CancellationToken.None),CancellationToken.None); + var ops=new ArtifactFaultFiles { FailMove=true }; + var mirror=new MssqlExportMirror(ops); + AssertThrows(()=>mirror.CopyVerified(local,receipt,new MssqlMirrorOptions(Path.Combine(root,"failed-remote"),1),null,CancellationToken.None),"Move failure"); + Assert(ops.PruneCalls==0,"Failed mirror never prunes"); + var cancel=new CancellationTokenSource(); ops=new ArtifactFaultFiles { AfterMove=()=>cancel.Cancel() }; mirror=new MssqlExportMirror(ops); + string published=mirror.CopyVerified(local,receipt,new MssqlMirrorOptions(Path.Combine(root,"late-cancel"),1),null,cancel.Token); + Assert(File.Exists(published)&&mirror.MirrorPublishedPath==published&&ops.PruneCalls==0,"Late cancellation retains mirror and skips retention"); + ops=new ArtifactFaultFiles { FailPrune=true }; mirror=new MssqlExportMirror(ops); + published=mirror.CopyVerified(local,receipt,new MssqlMirrorOptions(Path.Combine(root,"prune-fail"),1),null,CancellationToken.None); + Assert(File.Exists(published)&&mirror.Warnings.Count>0,"Retention failure does not erase mirror publication"); + } + } + private static void Artifact_Adversarial() + { + string root=Path.Combine(Path.GetTempPath(),"mssql-fault-"+Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + try + { + string path=Path.Combine(root,"source_backup_fixture.sql"); File.WriteAllText(path,"old fixture"); + var stamp=MssqlExportArtifact.CaptureDestination(path,CancellationToken.None); + foreach(bool flush in new[]{false,true}) + { + var ops=new ArtifactFaultFiles { FailWrite=!flush, FailFlush=flush }; + using(var artifact=MssqlExportArtifact.Create(ArtifactRequest(path,stamp),ops)) + { + AssertThrows(()=>FinishArtifact(artifact),"Disk write/flush failure cannot create completed receipt"); + AssertThrows(()=>artifact.Publish(stamp,CancellationToken.None),"Faulted artifact cannot publish"); + } + Assert(File.ReadAllText(path)=="old fixture","Disk failure leaves old version intact"); + } + using(var locked=new FileStream(path,FileMode.Open,FileAccess.ReadWrite,FileShare.None)) + AssertThrows(()=>MssqlExportArtifact.CaptureDestination(path,CancellationToken.None),"Locked target is not absence"); + using(var artifact=MssqlExportArtifact.Create(ArtifactRequest(path,stamp))) + { + var receipt=FinishArtifact(artifact); + using(var cancel=new CancellationTokenSource()) + AssertThrows(()=>artifact.Verify(receipt,(done,total)=>cancel.Cancel(),cancel.Token),"Cancel while hashing"); + AssertThrows(()=>artifact.Publish(stamp,CancellationToken.None),"Canceled verifier cannot authorize publish"); + artifact.Verify(receipt,null,CancellationToken.None); artifact.Publish(stamp,CancellationToken.None); + string remote=Path.Combine(root,"remote");Directory.CreateDirectory(remote); + string prior=Path.Combine(remote,"old_backup_fixture.sql");File.WriteAllText(prior,"old fixture"); + var ops=new ArtifactFaultFiles { CollideMove=true }; var mirror=new MssqlExportMirror(ops); + AssertThrows(()=>mirror.CopyVerified(path,receipt,new MssqlMirrorOptions(remote,1),null,CancellationToken.None),"Mirror collision never overwrites"); + Assert(File.ReadAllText(ops.CollisionPath)=="collision fixture"&&File.Exists(prior)&&ops.PruneCalls==0,"Collision preserves other output and skips prune"); + ops=new ArtifactFaultFiles { FailWrite=true };mirror=new MssqlExportMirror(ops); + AssertThrows(()=>mirror.CopyVerified(path,receipt,new MssqlMirrorOptions(remote,1),null,CancellationToken.None),"Mirror disk full"); + Assert(ops.PruneCalls==0&&File.Exists(prior),"Copy failure never prunes"); + string active=Path.Combine(remote,".mysqlpunk-export-active.partial");File.WriteAllText(active,"other"); + string future=Path.Combine(remote,"future_backup_fixture.sql");File.WriteAllText(future,"old fixture");File.SetLastWriteTimeUtc(future,DateTime.UtcNow.AddDays(2)); + mirror=new MssqlExportMirror();string published=mirror.CopyVerified(path,receipt,new MssqlMirrorOptions(remote,1),null,CancellationToken.None); + Assert(File.Exists(published)&&File.Exists(active)&&mirror.Warnings.Count>0,"Future timestamps cannot prune newly published mirror or active partial"); + File.WriteAllText(path,"changed fixture");ops=new ArtifactFaultFiles();mirror=new MssqlExportMirror(ops); + AssertThrows(()=>mirror.CopyVerified(path,receipt,new MssqlMirrorOptions(remote,1),null,CancellationToken.None),"Changed published source cannot be mirrored as old receipt"); + Assert(ops.PruneCalls==0&&File.Exists(published),"Invalid mirror never prunes"); + } + } + finally { Directory.Delete(root,true); } + } + private static void Artifact_CooperativeGates() + { + string root=Path.Combine(Path.GetTempPath(),"mssql-gate-"+Guid.NewGuid().ToString("N"));Directory.CreateDirectory(root); + try + { + string path=Path.Combine(root,"target.sql");File.WriteAllText(path,"old fixture"); + var stamp=MssqlExportArtifact.CaptureDestination(path,CancellationToken.None); + using(var entered=new ManualResetEventSlim()) + using(var release=new ManualResetEventSlim()) + using(var captureStarted=new ManualResetEventSlim()) + using(var cancel=new CancellationTokenSource()) + { + var ops=new ArtifactFaultFiles { BeforeReplace=()=> { entered.Set(); if(!release.Wait(10000)) throw new TimeoutException(); } }; + using(var first=MssqlExportArtifact.Create(ArtifactRequest(path,stamp),ops)) + using(var second=MssqlExportArtifact.Create(ArtifactRequest(path,stamp))) + { + var receipt=FinishArtifact(first);first.Verify(receipt,null,CancellationToken.None); + receipt=FinishArtifact(second);second.Verify(receipt,null,CancellationToken.None); + var publish=System.Threading.Tasks.Task.Run(()=>first.Publish(stamp,CancellationToken.None)); + Assert(entered.Wait(10000),"Publisher enters replacement"); + var capture=System.Threading.Tasks.Task.Run(()=> {captureStarted.Set();return MssqlExportArtifact.CaptureDestination(path,cancel.Token);}); + try + { + Assert(captureStarted.Wait(10000),"Capture started");cancel.Cancel(); + AssertThrows(()=>capture.GetAwaiter().GetResult(),"Consent capture shares cancellable publication gate"); + } + finally { release.Set(); } + Assert(publish.GetAwaiter().GetResult()==path,"First publisher completes"); + AssertThrows(()=>second.Publish(stamp,CancellationToken.None),"Second cooperating job rejects stale consent"); + } + } + } + finally { Directory.Delete(root,true); } + } + private sealed class ArtifactFailureStream : Stream + { + private readonly Stream inner; private readonly bool write,flush; + internal ArtifactFailureStream(Stream inner,bool write,bool flush){this.inner=inner;this.write=write;this.flush=flush;} + public override void Write(byte[] b,int o,int c){if(write)throw new IOException("Injected disk full");inner.Write(b,o,c);} + public override void Flush(){if(flush)throw new IOException("Injected flush full");inner.Flush();} + protected override void Dispose(bool disposing){if(disposing)inner.Dispose();base.Dispose(disposing);} + public override bool CanRead=>false;public override bool CanSeek=>false;public override bool CanWrite=>true; + public override long Length=>inner.Length;public override long Position{get=>inner.Position;set=>throw new NotSupportedException();} + public override int Read(byte[] b,int o,int c)=>throw new NotSupportedException();public override long Seek(long o,SeekOrigin k)=>throw new NotSupportedException();public override void SetLength(long l)=>throw new NotSupportedException(); + } + private sealed class ArtifactFaultFiles : MssqlArtifactFiles + { + internal int ReplaceFailure; internal bool FailMove, FailDelete, FailPrune; internal int PruneCalls; internal Action AfterMove, BeforeReplace; internal bool FailWrite, FailFlush, CollideMove; internal string CollisionPath; + internal override Stream Create(string path) { var stream=base.Create(path); return FailWrite||FailFlush ? new ArtifactFailureStream(stream,FailWrite,FailFlush) : stream; } + internal override void Replace(string source,string target,string recovery) + { + BeforeReplace?.Invoke(); + if(ReplaceFailure==3) File.Move(target,recovery); + if(ReplaceFailure>0) throw new IOException("Injected documented replace failure " + ReplaceFailure); + base.Replace(source,target,recovery); + } + internal override void Move(string source,string target) { if(FailMove) throw new IOException("Injected rename failure"); if(CollideMove) {CollisionPath=target;File.WriteAllText(target,"collision fixture");} base.Move(source,target); AfterMove?.Invoke(); } + internal override void Delete(string path) { if(FailDelete) throw new IOException("Injected cleanup failure"); base.Delete(path); } + internal override void Prune(string directory,int count) { PruneCalls++; if(FailPrune) throw new IOException("Injected retention failure"); base.Prune(directory,count); } + } +} diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index ba6fcdb..0c65cea 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -32,6 +32,9 @@ public static int Main() Run("MSSQL export metadata startup cancellation", Metadata_CancelsBeforeAndDuringReaderStartup, ref passed); Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); Run("MSSQL export metadata native reader", Metadata_RequiresNativeReader, ref passed); + Run("MSSQL export artifact pipeline", Artifact_Pipeline, ref passed); + Run("MSSQL export artifact failures", Artifact_Adversarial, ref passed); + Run("MSSQL export artifact cooperative gates", Artifact_CooperativeGates, ref passed); Run("MSSQL export session owner", Session_DisposesOwnerOnce, ref passed); Run("MSSQL export session request", Request_CopiesSelectionAndRejectsInvalidMode, ref passed); Run("Geometry WKB 轉 WKT", TestGeometryWktConverter, ref passed); From 0ca3f639f062b1fbede431d0ecf34ccec51a2da5 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:24:54 +0800 Subject: [PATCH 08/32] fix(mssql-export): bind publication to original consent --- mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs | 11 +++++++++-- tests/SmokeTests.MssqlExport.Artifact.cs | 12 ++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs b/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs index cafdb8b..f9b5a06 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportArtifact.cs @@ -130,13 +130,14 @@ internal static string Hash(Stream stream, Action progress, Cancella internal string Publish(ExportDestinationStamp expectedDestination, CancellationToken cancellation) { if (disposed || !verified || PublishedPath != null) throw new InvalidOperationException("Artifact must be verified and unpublished"); - if (expectedDestination == null || !string.Equals(expectedDestination.FullPath, request.TargetPath, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Consent stamp does not match destination"); + var approved = request.Destination; + if (approved == null || !string.Equals(approved.FullPath, request.TargetPath, StringComparison.OrdinalIgnoreCase) || !SameDestination(expectedDestination, approved)) throw new ArgumentException("Consent stamp does not match the immutable request"); var gate = MssqlArtifactGates.Get(request.TargetPath); gate.Wait(cancellation); try { VerifyFile(TemporaryPath, receipt, null, cancellation); var actual = CaptureCore(request.TargetPath, cancellation); - if (actual.Exists != expectedDestination.Exists || actual.FileIdentity != expectedDestination.FileIdentity || actual.Length != expectedDestination.Length || actual.LastWriteTimeUtc != expectedDestination.LastWriteTimeUtc || actual.Sha256 != expectedDestination.Sha256) throw new IOException("Destination changed after overwrite consent"); + if (!SameDestination(actual, approved)) throw new IOException("Destination changed after overwrite consent"); using (var commit = new MssqlPublicationCommit(cancellation)) { commit.Enter(); @@ -155,6 +156,12 @@ internal string Publish(ExportDestinationStamp expectedDestination, Cancellation } finally { gate.Release(); } } + private static bool SameDestination(ExportDestinationStamp left, ExportDestinationStamp right) + { + return left != null && right != null && string.Equals(left.FullPath, right.FullPath, StringComparison.OrdinalIgnoreCase) + && left.Exists == right.Exists && left.FileIdentity == right.FileIdentity && left.Length == right.Length + && left.LastWriteTimeUtc == right.LastWriteTimeUtc && left.Sha256 == right.Sha256; + } private void ObserveCandidate(string path) { try { File.GetAttributes(path); if (!residuals.Contains(path)) residuals.Add(path); } diff --git a/tests/SmokeTests.MssqlExport.Artifact.cs b/tests/SmokeTests.MssqlExport.Artifact.cs index 7e701ba..de8296d 100644 --- a/tests/SmokeTests.MssqlExport.Artifact.cs +++ b/tests/SmokeTests.MssqlExport.Artifact.cs @@ -57,6 +57,18 @@ private static void Artifact_Pipeline() AssertThrows(() => new MssqlExportMirror().CopyVerified(target, receipt, new MssqlMirrorOptions(remote, 1), null, canceled.Token), "Canceled mirror must not prune"); Assert(File.Exists(mirrorPath), "Canceled mirror preserves old backups"); } + string consentPath = Path.Combine(root,"consent.sql"); + File.WriteAllText(consentPath,"approved fixture A"); + var approved = MssqlExportArtifact.CaptureDestination(consentPath,CancellationToken.None); + var approvedRequest = ArtifactRequest(consentPath,approved); + File.WriteAllText(consentPath,"changed fixture B"); + var refreshed = MssqlExportArtifact.CaptureDestination(consentPath,CancellationToken.None); + using(var artifact=MssqlExportArtifact.Create(approvedRequest)) + { + var receipt=FinishArtifact(artifact);artifact.Verify(receipt,null,CancellationToken.None); + AssertThrows(()=>artifact.Publish(refreshed,CancellationToken.None),"Recaptured stamp cannot refresh immutable request consent"); + Assert(File.ReadAllText(consentPath)=="changed fixture B"&&artifact.PublishedPath==null,"Rejected refreshed consent preserves newer destination"); + } string corrupt = Path.Combine(root,"postverify.sql"); var absent = MssqlExportArtifact.CaptureDestination(corrupt,CancellationToken.None); using(var artifact=MssqlExportArtifact.Create(ArtifactRequest(corrupt,absent))) From a2b0480bde361aa18f775bfd49ec413171853986 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:41:57 +0800 Subject: [PATCH 09/32] feat(mssql-export): stream typed row values within batch budgets --- .../lib/MssqlExport/MssqlDataScriptWriter.cs | 158 ++++++++++++++++++ .../lib/MssqlExport/MssqlExportSource.cs | 2 +- mySQLPunk/lib/MssqlExport/MssqlValueCodec.cs | 94 +++++++++++ mySQLPunk/mySQLPunk.csproj | 2 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Values.cs | 145 ++++++++++++++++ tests/SmokeTests.cs | 4 + 7 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs create mode 100644 mySQLPunk/lib/MssqlExport/MssqlValueCodec.cs create mode 100644 tests/SmokeTests.MssqlExport.Values.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs new file mode 100644 index 0000000..9c0d5de --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + // Task5 consumes this same reader synchronously, finishes exactly one row, and returns + // additional source bytes consumed. It must not Read(), reopen, rewind, or own the reader. + internal interface IMssqlLargeRowWriter + { + long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, + MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation); + } + internal sealed class MssqlRowContinuation + { + private readonly char[] buffer; + private readonly int[] starts, lengths; + internal int Ordinal { get; } + internal long CurrentByteOffset { get; } + internal bool CurrentValueStarted { get; } + internal bool CurrentValueComplete { get; } + internal int BufferBytes { get { return buffer.Length * sizeof(char); } } + internal MssqlRowContinuation(char[] buffer, int[] starts, int[] lengths, int ordinal, long offset, bool started, bool complete) + { this.buffer = buffer; this.starts = starts; this.lengths = lengths; Ordinal = ordinal; CurrentByteOffset = offset; CurrentValueStarted = started; CurrentValueComplete = complete; } + // Completed columns contain full expressions; an open binary column contains prefix+hex, + // without suffix. No source bytes are pending outside this buffer. Valid only during callback. + internal int ValueLength(int ordinal) + { CheckOrdinal(ordinal); return lengths[ordinal]; } + internal int CopyValueChars(int ordinal, int sourceOffset, char[] destination, int destinationOffset, int count) + { + CheckOrdinal(ordinal); + if (sourceOffset < 0 || sourceOffset > lengths[ordinal]) throw new ArgumentOutOfRangeException(nameof(sourceOffset)); + int copied = Math.Min(count, lengths[ordinal] - sourceOffset); + Array.Copy(buffer, starts[ordinal] + sourceOffset, destination, destinationOffset, copied); + return copied; + } + private void CheckOrdinal(int ordinal) + { if (ordinal < 0 || ordinal > Ordinal) throw new ArgumentOutOfRangeException(nameof(ordinal)); } + } + internal sealed class MssqlScriptBatchWriter + { + private readonly TextWriter output; + private readonly Encoding encoding; + private int batchBytes; + internal MssqlScriptBatchWriter(TextWriter output) + { this.output = output ?? throw new ArgumentNullException(nameof(output)); encoding = output is StreamWriter ? output.Encoding : new UTF8Encoding(false, true); } + internal int Bytes(string text) { return encoding.GetByteCount(text); } + internal void WriteStatement(string statement) + { + int bytes = Bytes(statement) + Bytes("\r\n"); + if (bytes > MssqlExportLimits.StatementBytes) throw new InvalidOperationException("Statement exceeds the bounded script budget."); + if (batchBytes + bytes + Bytes("GO\r\n") > MssqlExportLimits.BatchBytes) Flush(); + output.Write(statement); output.Write("\r\n"); batchBytes += bytes; + } + internal bool TryWriteRow(string header, char[] values, int[] starts, int[] lengths) + { + int bytes = Bytes(header) + Bytes(lengths.Length == 0 ? "\r\n" : ");\r\n"); + for (int i = 0; i < lengths.Length; i++) bytes += encoding.GetByteCount(values, starts[i], lengths[i]) + (i == 0 ? 0 : Bytes(",")); + if (bytes > MssqlExportLimits.StatementBytes) return false; + if (batchBytes + bytes + Bytes("GO\r\n") > MssqlExportLimits.BatchBytes) Flush(); + output.Write(header); + for (int i = 0; i < lengths.Length; i++) { if (i > 0) output.Write(","); output.Write(values, starts[i], lengths[i]); } + output.Write(lengths.Length == 0 ? "\r\n" : ");\r\n"); + batchBytes += bytes; + return true; + } + internal void Flush() { if (batchBytes == 0) return; output.Write("GO\r\n"); batchBytes = 0; } + } + internal sealed class MssqlDataScriptWriter + { + private readonly IMssqlLargeRowWriter largeRows; + internal MssqlDataScriptWriter(IMssqlLargeRowWriter largeRows = null) { this.largeRows = largeRows; } + internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWriter output, + Action onRowsAndSourceBytes, CancellationToken cancellation) + { + if (reader == null) throw new ArgumentNullException(nameof(reader)); + if (table == null) throw new ArgumentNullException(nameof(table)); + var columns = table.Columns.Where(c => !c.Computed && !c.RowVersion).OrderBy(c => c.Ordinal).ToList().AsReadOnly(); + string target = MssqlValueCodec.QuoteIdentifier(table.Key.Schema) + "." + MssqlValueCodec.QuoteIdentifier(table.Key.Name); + string header = "INSERT INTO " + target + (columns.Count == 0 ? " DEFAULT VALUES;" : " (" + string.Join(",", columns.Select(c => MssqlValueCodec.QuoteIdentifier(c.Name))) + ") VALUES ("); + var batches = new MssqlScriptBatchWriter(output); + // Buffer plus scratch and column offsets stay under SmallRowBytes. The encoded + // statement is separately measured; conservative capacity is not a source limit. + int capacity = (MssqlExportLimits.SmallRowBytes - 8192 - columns.Count * 8) / 2; + if (capacity < 1024) throw new NotSupportedException("Column metadata exceeds row buffer budget."); + var buffer = new char[capacity]; var chunk = new byte[4096]; + var starts = new int[columns.Count]; var lengths = new int[columns.Count]; + long rows = 0, sourceBytes = 0; bool identity = columns.Any(c => c.Identity), identityOn = false; + cancellation.ThrowIfCancellationRequested(); + while (reader.Read()) + { + cancellation.ThrowIfCancellationRequested(); + if (identity && !identityOn) { batches.WriteStatement("SET IDENTITY_INSERT " + target + " ON;"); identityOn = true; } + int used = 0; bool handedOff = false; + for (int i = 0; i < columns.Count; i++) + { + cancellation.ThrowIfCancellationRequested(); + starts[i] = used; lengths[i] = 0; + var column = columns[i]; + bool binary = MssqlValueCodec.IsBinaryProjected(column); + // Fixed-width scalar expressions are bounded (<100 chars). Do not consume + // the next column when there is insufficient room; Task5 starts it itself. + if (capacity - used < 128) + { sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, 0, false, false), batches, cancellation); handedOff = true; break; } + if (reader.IsDBNull(i)) { Append(buffer, ref used, "NULL"); lengths[i] = used - starts[i]; continue; } + if (!binary) + { + Append(buffer, ref used, MssqlValueCodec.FormatScalar(MssqlValueCodec.ReadScalar(reader, i, column), column)); + sourceBytes += MssqlValueCodec.ScalarBytes(column); lengths[i] = used - starts[i]; continue; + } + string suffix = MssqlValueCodec.BinarySuffix(column); + Append(buffer, ref used, MssqlValueCodec.BinaryPrefix(column)); + long offset = 0; + while (true) + { + cancellation.ThrowIfCancellationRequested(); + int count = Math.Min(chunk.Length, (capacity - used - suffix.Length) / 2); + if (count == 0) + { + lengths[i] = used - starts[i]; + sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, offset, true, false), batches, cancellation); handedOff = true; break; + } + int read = checked((int)reader.GetBytes(i, offset, chunk, 0, count)); + if (read == 0) { Append(buffer, ref used, suffix); break; } + for (int j = 0; j < read; j++) { buffer[used++] = "0123456789ABCDEF"[chunk[j] >> 4]; buffer[used++] = "0123456789ABCDEF"[chunk[j] & 15]; } + offset += read; sourceBytes += read; + } + lengths[i] = used - starts[i]; + if (handedOff) break; + } + if (!handedOff) + { + if (!batches.TryWriteRow(header, buffer, starts, lengths)) + { + int last = columns.Count - 1; + sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, last, 0, true, true), batches, cancellation); + } + } + rows++; onRowsAndSourceBytes?.Invoke(rows, sourceBytes); + cancellation.ThrowIfCancellationRequested(); + } + if (identityOn) batches.WriteStatement("SET IDENTITY_INSERT " + target + " OFF;"); + batches.Flush(); + return rows; + } + private long Handoff(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation) + { + if (largeRows == null) throw new NotImplementedException("Large row continuation requires the Task5 streaming staging writer; the export UI is not connected yet."); + return largeRows.WriteRow(reader, table, columns, row, output, cancellation); + } + private static void Append(char[] buffer, ref int used, string value) + { value.CopyTo(0, buffer, used, value.Length); used += value.Length; } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs index 5a87d48..b4104a6 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs @@ -74,7 +74,7 @@ public MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken can cancellation.ThrowIfCancellationRequested(); var columns = table.Columns.Where(c => !c.Computed && !c.RowVersion).OrderBy(c => c.Ordinal).ToList(); // A rowversion/computed-only table still needs a row count for DEFAULT VALUES. - string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(c => MssqlExportMetadataReader.Quote(c.Name))); + string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(MssqlValueCodec.SourceExpression)); var command = new SqlCommand("SELECT " + projection + " FROM " + MssqlExportMetadataReader.Qualified(table.Key), session.Connection, transaction) { CommandTimeout = 0 }; var owner = new MssqlCommandOwner(command, cancellation); try { activeReader = new MssqlRowReader(owner.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } diff --git a/mySQLPunk/lib/MssqlExport/MssqlValueCodec.cs b/mySQLPunk/lib/MssqlExport/MssqlValueCodec.cs new file mode 100644 index 0000000..ae585de --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlValueCodec.cs @@ -0,0 +1,94 @@ +using System; +using System.Data.Common; +using System.Data.SqlTypes; +using System.Globalization; +using System.Linq; + +namespace mySQLPunk.lib.MssqlExport +{ + internal static class MssqlValueCodec + { + private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + internal static string QuoteIdentifier(string identifier) + { + if (identifier == null) throw new ArgumentNullException(nameof(identifier)); + return "[" + identifier.Replace("]", "]]") + "]"; + } + internal static bool IsBinaryProjected(MssqlColumnDefinition column) + { + switch (column.NativeType) { + case "varchar": case "char": case "text": case "nvarchar": case "nchar": case "ntext": + case "xml": case "binary": case "varbinary": case "image": return true; + default: return false; + } + } + internal static string SourceExpression(MssqlColumnDefinition column) + { + string name = QuoteIdentifier(column.Name); + if (!IsBinaryProjected(column)) return name; + if (column.NativeType == "text") name = "CONVERT(varchar(max)," + name + ")"; + if (column.NativeType == "ntext" || column.NativeType == "xml") name = "CONVERT(nvarchar(max)," + name + ")"; + return "CONVERT(varbinary(max)," + name + ")"; + } + internal static string BinaryPrefix(MssqlColumnDefinition column) + { + switch (column.NativeType) { + case "xml": return "CONVERT(xml,CONVERT(nvarchar(max),0x"; + case "nvarchar": case "nchar": case "ntext": return "CONVERT(nvarchar(max),0x"; + default: return "0x"; // Direct binary assignment preserves ANSI bytes in the target collation. + } + } + internal static string BinarySuffix(MssqlColumnDefinition column) + { + switch (column.NativeType) { + case "xml": return "),1)"; + case "nvarchar": case "nchar": case "ntext": return ")"; + default: return ""; + } + } + internal static object ReadScalar(DbDataReader reader, int ordinal, MssqlColumnDefinition column) + { + if (column.NativeType == "decimal" || column.NativeType == "numeric") + return reader.GetProviderSpecificValue(ordinal); // SqlDecimal supports precision 38. + return reader.GetValue(ordinal); // Only fixed-width types reach this method. + } + internal static int ScalarBytes(MssqlColumnDefinition column) + { + if (column.Length > 0) return column.Length; + switch (column.NativeType) { + case "bit": case "tinyint": return 1; + case "smallint": return 2; + case "int": case "real": case "smallmoney": case "smalldatetime": return 4; + case "uniqueidentifier": return 16; + case "decimal": case "numeric": return 17; + case "datetimeoffset": return 10; + case "date": return 3; + case "time": return 5; + default: return 8; + } + } + internal static string FormatScalar(object value, MssqlColumnDefinition column) + { + if (value == null || value == DBNull.Value || (value is INullable && ((INullable)value).IsNull)) return "NULL"; + switch (column.NativeType) { + case "decimal": case "numeric": + // SqlDecimal.ToString is invariant and retains the declared scale without CLR decimal conversion. + if (value is SqlDecimal) return ((SqlDecimal)value).ToString(); + if (value is decimal) return ((decimal)value).ToString(Invariant); + throw new InvalidOperationException("Expected provider SqlDecimal."); + case "money": case "smallmoney": + return (value is SqlMoney ? ((SqlMoney)value).Value : (decimal)value).ToString("0.0000", Invariant); + case "bit": return (bool)value ? "1" : "0"; + case "tinyint": case "smallint": case "int": case "bigint": return Convert.ToString(value, Invariant); + case "float": return ((double)value).ToString("R", Invariant); + case "real": return ((float)value).ToString("R", Invariant); + case "uniqueidentifier": return "'" + ((Guid)value).ToString("D") + "'"; + case "date": return "'" + ((DateTime)value).ToString("yyyy-MM-dd", Invariant) + "'"; + case "datetime": case "datetime2": case "smalldatetime": return "'" + ((DateTime)value).ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", Invariant) + "'"; + case "datetimeoffset": return "'" + ((DateTimeOffset)value).ToString("yyyy-MM-dd'T'HH:mm:ss.fffffffzzz", Invariant) + "'"; + case "time": return "'" + ((TimeSpan)value).ToString(@"hh\:mm\:ss\.fffffff", Invariant) + "'"; + default: throw new NotSupportedException("No scalar codec for " + column.NativeType + "."); + } + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 4555a7b..8f9f955 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -365,6 +365,8 @@ + + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index 97190b7..f533bc4 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Values.cs b/tests/SmokeTests.MssqlExport.Values.cs new file mode 100644 index 0000000..47cf1a4 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Values.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data.Common; +using System.Data.SqlTypes; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static MssqlColumnDefinition ValueColumn(string type, int id = 1, string name = "v", bool identity = false) + { return new MssqlColumnDefinition(new Dictionary { { "type_name", type }, { "column_id", id }, { "name", name }, { "is_identity", identity }, { "scale", 7 }, { "max_length", -1 } }); } + private static MssqlTableDefinition ValueTable(params MssqlColumnDefinition[] columns) + { return new MssqlTableDefinition(new MssqlObjectKey(1, "dbo", "t"), new Dictionary(), columns, null, null); } + private static void Values_Scalars() + { + var previous = CultureInfo.CurrentCulture; + try { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + AssertEquals("[a]]b]", MssqlValueCodec.QuoteIdentifier("a]b"), "Escape identifiers"); + AssertEquals("1234567890123456789012345678.1234567890", MssqlValueCodec.FormatScalar(SqlDecimal.Parse("1234567890123456789012345678.1234567890"), ValueColumn("decimal")), "38 decimal digits"); + AssertEquals("12.3400", MssqlValueCodec.FormatScalar(new SqlMoney(12.34m), ValueColumn("money")), "Money precision"); + AssertEquals("'2026-10-12T03:04:05.1234567+08:00'", MssqlValueCodec.FormatScalar(new DateTimeOffset(2026,10,12,3,4,5,TimeSpan.FromHours(8)).AddTicks(1234567), ValueColumn("datetimeoffset")), "Offset and 7 digits"); + AssertEquals("'03:04:05.1234567'", MssqlValueCodec.FormatScalar(new TimeSpan(0,3,4,5).Add(TimeSpan.FromTicks(1234567)), ValueColumn("time")), "Time ticks"); + AssertEquals("'2026-10-12'", MssqlValueCodec.FormatScalar(new DateTime(2026,10,12), ValueColumn("date")), "ISO date"); + AssertEquals("'2026-10-12T03:04:05.1234567'", MssqlValueCodec.FormatScalar(new DateTime(2026,10,12,3,4,5).AddTicks(1234567), ValueColumn("datetime2")), "Date ticks"); + AssertEquals("'00112233-4455-6677-8899-aabbccddeeff'", MssqlValueCodec.FormatScalar(new Guid("00112233-4455-6677-8899-aabbccddeeff"), ValueColumn("uniqueidentifier")), "GUID"); + double number = 1.2345678901234567; + Assert(double.Parse(MssqlValueCodec.FormatScalar(number, ValueColumn("float")), CultureInfo.InvariantCulture) == number, "Float roundtrip"); + AssertEquals("NULL", MssqlValueCodec.FormatScalar(DBNull.Value, ValueColumn("int")), "Null"); + } finally { CultureInfo.CurrentCulture = previous; } + } + private static void Values_ProjectionAndStreaming() + { + AssertEquals("CONVERT(varbinary(max),CONVERT(varchar(max),[v]))", MssqlValueCodec.SourceExpression(ValueColumn("text")), "Legacy ANSI bytes"); + AssertEquals("CONVERT(varbinary(max),CONVERT(nvarchar(max),[v]))", MssqlValueCodec.SourceExpression(ValueColumn("xml")), "XML serialized UTF16"); + var data = new byte[] { 0x47,0,0x4f,0,0x0a,0,0x3a,0,0x72,0,0,0,0x24,0,0x28,0,0x78,0,0x29,0,0,0xd8 }; + var table = ValueTable(ValueColumn("int", 2,"id",true), ValueColumn("nvarchar",7,"u"),ValueColumn("varchar",8,"a"),ValueColumn("xml",12,"x"),ValueColumn("decimal",15,"d")); + using(var reader = new ValueReader(new object[][] { new object[] { 4,data,new byte[]{255,0},new byte[]{60,0,114,0,47,0,62,0},SqlDecimal.Parse("1234567890123456789012345678.1234567890") } })) + using(var output = new StringWriter(CultureInfo.InvariantCulture)) { + long progressRows=0, progressBytes=0; + Assert(new MssqlDataScriptWriter().WriteTable(reader,table,output,(r,b)=>{ progressRows=r; progressBytes=b; },CancellationToken.None)==1,"One row"); + string sql=output.ToString(); + Assert(sql.Contains("SET IDENTITY_INSERT [dbo].[t] ON;") && sql.Contains("SET IDENTITY_INSERT [dbo].[t] OFF;"),"Identity lifetime"); + Assert(sql.Contains("CONVERT(nvarchar(max),0x47004F000A003A0072000000240028007800290000D8)"),"Exact UTF16 including unpaired surrogate"); + Assert(sql.Contains(",0xFF00,CONVERT(xml,CONVERT(nvarchar(max),0x3C0072002F003E00),1),"),"Direct ANSI binary and XML style1"); + Assert(sql.Contains("1234567890123456789012345678.1234567890"),"Provider decimal path"); + Assert(!sql.Contains("$(") && !sql.Contains(":r"),"Data cannot escape literal"); + Assert(progressRows==1 && progressBytes==53,"Source bytes count includes scalars and raw projection"); + } + using(var reader = new ValueReader(new object[][] { new object[]{null,new byte[0]},new object[]{new byte[0],null} })) + using(var output = new StringWriter()) { + new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varchar",1,"a"),ValueColumn("nvarchar",2,"b")),output,null,CancellationToken.None); + Assert(output.ToString().Contains("VALUES (NULL,CONVERT(nvarchar(max),0x));") && output.ToString().Contains("VALUES (0x,NULL);"),"Empty differs from NULL"); + } + } + private static void Values_BudgetsAndHandoff() + { + var rows=Enumerable.Range(0,80).Select(i=>new object[]{new byte[40000]}).ToArray(); + using(var reader=new ValueReader(rows)) using(var output=new StringWriter()) { + Assert(new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varbinary")),output,null,CancellationToken.None)==80,"Batch rows"); + foreach(string batch in output.ToString().Split(new[]{"GO\r\n"},StringSplitOptions.RemoveEmptyEntries)) { + Assert(Encoding.UTF8.GetByteCount(batch)<=4194304,"Actual batch budget"); + foreach(string statement in batch.Split(new[]{"\r\n"},StringSplitOptions.RemoveEmptyEntries)) Assert(Encoding.UTF8.GetByteCount(statement+"\r\n")<=262144,"Actual statement budget"); + } + } + using(var reader=new ValueReader(new[]{new object[]{5,new byte[300000],9}})) using(var output=new StringWriter()) { + var sink=new ValueContinuationSink(); + Assert(new MssqlDataScriptWriter(sink).WriteTable(reader,ValueTable(ValueColumn("int",1,"a"),ValueColumn("varbinary",4,"b"),ValueColumn("int",9,"c")),output,null,CancellationToken.None)==1,"Handoff finishes source row"); + Assert(sink.Bytes==300000 && sink.PrefixValue=="5" && sink.Tail==9,"Handoff consumes retained prefix and forward-only remainder"); + } + using(var reader=new ValueReader(new[]{new object[]{new byte[300000]}})) using(var output=new StringWriter()) + AssertThrows(()=>new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varbinary")),output,null,CancellationToken.None),"Task5 absent explicitly"); + } + private static void Values_DefaultAndCancel() + { + using(var reader=new ValueReader(new[]{new object[]{true},new object[]{true}})) using(var output=new StringWriter()) { + Assert(new MssqlDataScriptWriter().WriteTable(reader,ValueTable(),output,null,CancellationToken.None)==2,"Default rows"); + Assert(output.ToString().Split(new[]{"INSERT INTO [dbo].[t] DEFAULT VALUES;"},StringSplitOptions.None).Length==3,"Each default row"); + } + var computed=new MssqlColumnDefinition(new Dictionary{{"type_name","int"},{"column_id",1},{"name","computed"},{"is_computed",true}}); + using(var reader=new ValueReader(new[]{new object[]{7}})) using(var output=new StringWriter()) { + new MssqlDataScriptWriter().WriteTable(reader,ValueTable(computed,ValueColumn("rowversion",2,"rv"),ValueColumn("int",8,"kept")),output,null,CancellationToken.None); + Assert(output.ToString().Contains("([kept]) VALUES (7);") && !output.ToString().Contains("[computed]") && !output.ToString().Contains("[rv]"),"Exclude computed and rowversion; contiguous projection ordinals"); + } + using(var reader=new ValueReader(new object[0][])) using(var output=new StringWriter()) Assert(new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("int")),output,null,CancellationToken.None)==0 && output.ToString()=="","Empty table"); + using(var reader=new ValueReader(new[]{new object[]{1}})) using(var output=new StringWriter()) using(var cancel=new CancellationTokenSource()) { cancel.Cancel(); AssertThrows(()=>new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("int")),output,null,cancel.Token),"Cancel before read"); } + } + private sealed class ValueContinuationSink : IMssqlLargeRowWriter + { + internal long Bytes; internal string PrefixValue; internal int Tail; + public long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation) { + var prefix=new char[row.ValueLength(0)]; row.CopyValueChars(0,0,prefix,0,prefix.Length); PrefixValue=new string(prefix); Bytes=row.CurrentByteOffset; + Assert(row.BufferBytes<=262144,"Bounded retained row buffer"); + var bytes=new byte[4096]; int n; + while((n=(int)reader.GetBytes(row.Ordinal,Bytes,bytes,0,bytes.Length))!=0) Bytes+=n; + Tail=reader.GetInt32(row.Ordinal+1); + output.WriteStatement("SELECT 1;"); + return 300004-row.CurrentByteOffset; + } + } + // SQL projection exposes variable-size fields as binary. Reject whole-field and backward access. + private sealed class ValueReader : DbDataReader + { + private readonly object[][] rows; private int row=-1, ordinal=-1; private long offset; + internal ValueReader(object[][] rows) { this.rows=rows; } + private object At(int i) { if(iordinal){ordinal=i;offset=0;} return rows[row][i]; } + public override bool Read(){row++;ordinal=-1;offset=0;return row65536)throw new InvalidOperationException("Unbounded or nonsequential");int n=(int)Math.Min(count,b.LongLength-start);Array.Copy(b,start,buffer,index,n);offset+=n;return n;} + public override string GetString(int i){throw new InvalidOperationException("Whole string");} + public override long GetChars(int i,long a,char[] b,int c,int d){throw new NotSupportedException();} + public override int GetInt32(int i){return (int)GetValue(i);} + public override bool GetBoolean(int i){return (bool)GetValue(i);} + public override byte GetByte(int i){return (byte)GetValue(i);} + public override short GetInt16(int i){return (short)GetValue(i);} + public override long GetInt64(int i){return (long)GetValue(i);} + public override decimal GetDecimal(int i){throw new InvalidOperationException("Decimal narrowing");} + public override float GetFloat(int i){return (float)GetValue(i);} + public override double GetDouble(int i){return (double)GetValue(i);} + public override Guid GetGuid(int i){return (Guid)GetValue(i);} + public override DateTime GetDateTime(int i){return (DateTime)GetValue(i);} + public override char GetChar(int i){throw new NotSupportedException();} + public override int FieldCount{get{return rows.Length==0?0:rows[0].Length;}} + public override bool HasRows{get{return rows.Length>0;}} + public override bool IsClosed{get{return false;}} + public override int RecordsAffected{get{return -1;}} + public override int Depth{get{return 0;}} + public override object this[int i]{get{return GetValue(i);}} + public override object this[string name]{get{throw new NotSupportedException();}} + public override string GetName(int i){throw new NotSupportedException();} + public override int GetOrdinal(string name){throw new NotSupportedException();} + public override string GetDataTypeName(int i){throw new NotSupportedException();} + public override Type GetFieldType(int i){throw new NotSupportedException();} + public override int GetValues(object[] values){throw new NotSupportedException();} + public override IEnumerator GetEnumerator(){throw new NotSupportedException();} + public override bool NextResult(){return false;} + } +} diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 0c65cea..3f55b4a 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -32,6 +32,10 @@ public static int Main() Run("MSSQL export metadata startup cancellation", Metadata_CancelsBeforeAndDuringReaderStartup, ref passed); Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); Run("MSSQL export metadata native reader", Metadata_RequiresNativeReader, ref passed); + Run("MSSQL export values scalars", Values_Scalars, ref passed); + Run("MSSQL export values projection and streaming", Values_ProjectionAndStreaming, ref passed); + Run("MSSQL export values budgets and handoff", Values_BudgetsAndHandoff, ref passed); + Run("MSSQL export values default and cancellation", Values_DefaultAndCancel, ref passed); Run("MSSQL export artifact pipeline", Artifact_Pipeline, ref passed); Run("MSSQL export artifact failures", Artifact_Adversarial, ref passed); Run("MSSQL export artifact cooperative gates", Artifact_CooperativeGates, ref passed); From 4e5d3e14c079ade4a7ea0ec3c2c8e8f3775b9b41 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:42:31 +0800 Subject: [PATCH 10/32] feat(mssql-export): script native schema and ordered dependencies --- .../MssqlExport/MssqlExportMetadataReader.cs | 5 +- .../MssqlExport/MssqlSchemaScriptWriter.cs | 268 ++++++++++++++++++ mySQLPunk/mySQLPunk.csproj | 1 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Schema.cs | 131 +++++++++ tests/SmokeTests.cs | 6 + 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs create mode 100644 tests/SmokeTests.MssqlExport.Schema.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs index 25ab845..8078040 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs @@ -98,8 +98,10 @@ internal IReadOnlyList Validate(MssqlExportSchema schema, Mssq if (t.Text("can_select") != null && !t.Flag("can_select")) add("UnreadableTable", null, "SELECT permission is required."); foreach (var flag in new[] { "temporal_type", "ledger_type", "is_memory_optimized", "is_filetable", "is_node", "is_edge", "is_external", "is_remote_data_archive_enabled", "trigger_count", "fulltext_count", "security_policy_count", "unsupported_properties" }) if (t.Number(flag) != 0) add("UnsupportedTableFeature", null, "Unsupported table feature: " + flag); + if (request.Mode != MssqlExportMode.DataOnly && t.Text("uses_ansi_nulls") != null && !t.Flag("uses_ansi_nulls")) add("UnsupportedAnsiNulls", null, "Legacy ANSI_NULLS OFF cannot be recreated on modern SQL Server."); foreach (var c in t.Columns) { + if (request.Mode != MssqlExportMode.DataOnly && c.Text("is_ansi_padded") != null && !c.Flag("is_ansi_padded") && new[] { "char", "varchar", "binary", "varbinary" }.Contains(c.NativeType)) add("UnsupportedAnsiPadding", c.Name, "Legacy ANSI_PADDING OFF cannot be recreated on modern SQL Server."); if (c.NativeType == "geometry" || c.NativeType == "geography") add("UnsupportedSpatial", c.Name, "Native spatial values require a future codec."); else if (c.Flag("is_user_defined") || c.Flag("is_assembly_type")) add("UnsupportedUdt", c.Name, "Alias and CLR user types are not supported."); if (c.Number("xml_collection_id") != 0) add("UnsupportedTypedXml", c.Name, "Typed XML schema collections are not exported."); @@ -111,7 +113,7 @@ internal IReadOnlyList Validate(MssqlExportSchema schema, Mssq } foreach (var i in t.Indexes) { - if (i.Number("type") > 2 || i.Text("data_space_type") == "PS" || i.Number("data_compression") != 0 || i.Flag("is_hypothetical")) add("UnsupportedIndex", null, "Unsupported index/partition/compression: " + i.Name); + if (i.Number("type") > 2 || i.Text("data_space_type") == "PS" || i.Number("data_compression") != 0 || i.Flag("is_hypothetical") || i.Flag("suppress_dup_key_messages")) add("UnsupportedIndex", null, "Unsupported index/partition/compression: " + i.Name); if (i.Flag("has_filter") && i.Text("filter_definition") == null) add("UnreadableDefinition", null, "Index filter unavailable: " + i.Name); } foreach (var c in t.Constraints) @@ -122,6 +124,7 @@ internal IReadOnlyList Validate(MssqlExportSchema schema, Mssq } foreach (var v in schema.Views) { + if (v.Text("uses_ansi_nulls") != null && !v.AnsiNulls) issues.Add(new MssqlExportIssue("UnsupportedAnsiNulls", v.Key, null, "Legacy view ANSI_NULLS OFF cannot be recreated on modern SQL Server.")); if (string.IsNullOrEmpty(v.Definition)) issues.Add(new MssqlExportIssue("UnreadableDefinition", v.Key, null, "View definition is encrypted or unreadable.")); if (v.Number("index_count") != 0 || v.Number("trigger_count") != 0 || v.Number("unsupported_properties") != 0) issues.Add(new MssqlExportIssue("UnsupportedViewFeature", v.Key, null, "Indexed views, view triggers and view extended properties are unsupported.")); } diff --git a/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs new file mode 100644 index 0000000..a79a6ba --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + // Native catalog DDL only. Data/IDENTITY_INSERT belong to the streaming data writer. + internal sealed class MssqlSchemaScriptWriter + { + internal void WriteBeforeData(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation) + { + Validate(schema, request, output, cancellation); + if (request.Mode == MssqlExportMode.DataOnly) return; + Settings(output); + output.WriteLine("-- Restore into an empty, compatible target selected by the caller; existing objects fail."); + output.WriteLine("-- Named filegroups must already exist. Rowversion values are regenerated."); + output.WriteLine("-- Identity preserves seed/increment and inserted values, not historical allocation gaps."); + foreach (string name in schema.Tables.Select(t => t.Key.Schema).Concat(schema.Views.Select(v => v.Key.Schema)).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal)) + { + cancellation.ThrowIfCancellationRequested(); + output.WriteLine("IF SCHEMA_ID(" + Literal(name) + ") IS NULL EXEC(" + Literal("CREATE SCHEMA " + Quote(name)) + ");"); + } + Batch(output); + foreach (var table in schema.Tables.OrderBy(t => t.Key.ObjectId)) + { + cancellation.ThrowIfCancellationRequested(); + output.WriteLine("CREATE TABLE " + Qualified(table.Key) + " ("); + var columns = table.Columns.OrderBy(c => c.Ordinal).ToArray(); + for (int i = 0; i < columns.Length; i++) + { + cancellation.ThrowIfCancellationRequested(); + output.Write(" " + Column(columns[i])); + output.WriteLine(i + 1 == columns.Length ? "" : ","); + } + var heap = table.Indexes.FirstOrDefault(i => i.Number("type") == 0); + output.WriteLine(")" + Placement(heap) + ";"); + if (table.Text("lock_escalation_desc") != null) + output.WriteLine("ALTER TABLE " + Qualified(table.Key) + " SET (LOCK_ESCALATION = " + table.Text("lock_escalation_desc") + ");"); + TableOption(output, table, "text_in_row_limit", "text in row"); + TableOption(output, table, "large_value_types_out_of_row", "large value types out of row"); + Batch(output); + } + } + + internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation) + { + Validate(schema, request, output, cancellation); + if (request.Mode == MssqlExportMode.DataOnly) return; + Settings(output); + // All candidate keys precede any FK, including cross-table cycles. + foreach (var table in schema.Tables.OrderBy(t => t.Key.ObjectId)) + foreach (var index in table.Indexes.Where(i => i.Number("type") != 0).OrderBy(i => i.Number("type")).ThenBy(i => i.IndexId)) + { + cancellation.ThrowIfCancellationRequested(); + WriteIndex(table, index, output); + } + foreach (var table in schema.Tables.OrderBy(t => t.Key.ObjectId)) + { + foreach (var constraint in table.Constraints.OrderBy(c => c.Name, StringComparer.Ordinal)) + { + cancellation.ThrowIfCancellationRequested(); + WriteConstraint(table, constraint, output); + } + Comment(output, table.Key, null, table.Comment); + foreach (var column in table.Columns) { cancellation.ThrowIfCancellationRequested(); Comment(output, table.Key, column.Name, column.Comment); } + } + var pending = schema.Views.ToDictionary(v => v.Key.ObjectId); + while (pending.Count != 0) + { + var ready = pending.Values.Where(v => !schema.Dependencies.Any(d => d.ObjectId == v.Key.ObjectId && d.ReferencedId.HasValue && pending.ContainsKey(d.ReferencedId.Value))).OrderBy(v => v.Key.ObjectId).ToArray(); + if (ready.Length == 0) throw new InvalidOperationException("View dependency cycle."); + foreach (var view in ready) + { + cancellation.ThrowIfCancellationRequested(); + output.WriteLine("SET ANSI_NULLS " + OnOff(view.AnsiNulls) + ";"); + output.WriteLine("SET QUOTED_IDENTIFIER " + OnOff(view.QuotedIdentifier) + ";"); + Batch(output); + output.WriteLine(ViewDefinition(view)); + Batch(output); + pending.Remove(view.Key.ObjectId); + } + } + // Disabling a clustered index first makes its table inaccessible and can disable FKs. + // Delay all requested disabled states until every object is in place. + Settings(output); + foreach (var table in schema.Tables) + foreach (var index in table.Indexes.Where(i => i.Flag("is_disabled")).OrderByDescending(i => i.Number("type"))) + { + cancellation.ThrowIfCancellationRequested(); + output.WriteLine("ALTER INDEX " + Quote(index.Name) + " ON " + Qualified(table.Key) + " DISABLE;"); + } + Batch(output); + } + + private static void Validate(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation) + { + if (schema == null) throw new ArgumentNullException(nameof(schema)); + if (request == null) throw new ArgumentNullException(nameof(request)); + if (output == null) throw new ArgumentNullException(nameof(output)); + cancellation.ThrowIfCancellationRequested(); + var issues = new MssqlExportMetadataReader().Validate(schema, request); + if (issues.Count != 0) throw new InvalidOperationException(string.Join("; ", issues.Select(i => i.Code + ": " + i.Message))); + if (request.Mode == MssqlExportMode.DataOnly) return; + foreach (var view in schema.Views) ViewDefinition(view); // Fail before any partial DDL. + foreach (var table in schema.Tables) + { + if (table.Columns.Count == 0) throw new InvalidOperationException("Table has no visible columns: " + Qualified(table.Key)); + foreach (var c in table.Constraints) + if (c.Kind != "FK" && c.Kind != "CHECK") throw new InvalidOperationException("Unsupported constraint: " + c.Kind); + } + } + private static string Column(MssqlColumnDefinition c) + { + var sql = new StringBuilder(Quote(c.Name)); + if (c.Computed) + return sql.Append(" AS ").Append(c.ComputedDefinition).Append(c.Persisted ? " PERSISTED" : "").Append(c.Persisted && !c.Nullable ? " NOT NULL" : "").ToString(); + sql.Append(' ').Append(NativeType(c)); + if (c.Collation != null) sql.Append(" COLLATE ").Append(c.Collation); + if (c.Identity) sql.Append(" IDENTITY(").Append(c.IdentitySeed).Append(',').Append(c.IdentityIncrement).Append(')').Append(c.Flag("identity_not_for_replication") ? " NOT FOR REPLICATION" : ""); + if (c.Flag("is_rowguidcol")) sql.Append(" ROWGUIDCOL"); + sql.Append(c.Nullable ? " NULL" : " NOT NULL"); + if (c.DefaultDefinition != null) sql.Append(" CONSTRAINT ").Append(Quote(c.DefaultName)).Append(" DEFAULT ").Append(c.DefaultDefinition); + return sql.ToString(); + } + private static string NativeType(MssqlColumnDefinition c) + { + string type = c.NativeType.ToLowerInvariant(); + if (c.RowVersion) return "rowversion"; + switch (type) + { + case "varchar": case "char": case "binary": case "varbinary": + return type + "(" + (c.Length == -1 ? "max" : Number(c.Length)) + ")"; + case "nvarchar": case "nchar": + return type + "(" + (c.Length == -1 ? "max" : Number(c.Length / 2)) + ")"; + case "decimal": case "numeric": return type + "(" + Number(c.Precision) + "," + Number(c.Scale) + ")"; + case "datetime2": case "datetimeoffset": case "time": return type + "(" + Number(c.Scale) + ")"; + case "float": return type + "(" + Number(c.Precision) + ")"; + default: return type; + } + } + private static void WriteIndex(MssqlTableDefinition table, MssqlIndexDefinition index, TextWriter output) + { + string keys = string.Join(", ", index.Columns.Where(c => c.Number("key_ordinal") > 0).OrderBy(c => c.Number("key_ordinal")).Select(c => Quote(c.Text("name")) + (c.Flag("is_descending_key") ? " DESC" : " ASC"))); + string kind = index.Number("type") == 1 ? "CLUSTERED" : "NONCLUSTERED"; + bool constraint = index.Flag("is_primary_key") || index.Flag("is_unique_constraint"); + string sql = constraint + ? "ALTER TABLE " + Qualified(table.Key) + " ADD CONSTRAINT " + Quote(index.Name) + (index.Flag("is_primary_key") ? " PRIMARY KEY " : " UNIQUE ") + kind + : "CREATE " + (index.Flag("is_unique") ? "UNIQUE " : "") + kind + " INDEX " + Quote(index.Name) + " ON " + Qualified(table.Key); + sql += " (" + keys + ")"; + var includes = index.Columns.Where(c => c.Flag("is_included_column")).OrderBy(c => c.Number("index_column_id")).Select(c => Quote(c.Text("name"))).ToArray(); + if (includes.Length != 0) sql += " INCLUDE (" + string.Join(", ", includes) + ")"; + if (index.Flag("has_filter")) sql += " WHERE " + index.Text("filter_definition"); + var options = new List(); + foreach (var pair in new[] { new[] {"is_padded","PAD_INDEX"}, new[] {"ignore_dup_key","IGNORE_DUP_KEY"}, new[] {"allow_row_locks","ALLOW_ROW_LOCKS"}, new[] {"allow_page_locks","ALLOW_PAGE_LOCKS"}, new[] {"no_recompute","STATISTICS_NORECOMPUTE"}, new[] {"optimize_for_sequential_key","OPTIMIZE_FOR_SEQUENTIAL_KEY"} }) + if (index.Text(pair[0]) != null) options.Add(pair[1] + " = " + OnOff(index.Flag(pair[0]))); + if (index.Number("fill_factor") != 0) options.Add("FILLFACTOR = " + Number(index.Number("fill_factor"))); + if (options.Count != 0) sql += " WITH (" + string.Join(", ", options) + ")"; + output.WriteLine(sql + Placement(index) + ";"); + Batch(output); + } + private static void WriteConstraint(MssqlTableDefinition table, MssqlConstraintDefinition c, TextWriter output) + { + string sql = "ALTER TABLE " + Qualified(table.Key) + (c.Flag("is_not_trusted") || c.Flag("is_disabled") ? " WITH NOCHECK" : " WITH CHECK") + " ADD CONSTRAINT " + Quote(c.Name) + " "; + if (c.Kind == "CHECK") sql += "CHECK " + (c.Flag("is_not_for_replication") ? "NOT FOR REPLICATION " : "") + c.Text("definition"); + else + { + var columns = c.Columns.OrderBy(x => x.Number("constraint_column_id")).ToArray(); + sql += "FOREIGN KEY (" + string.Join(", ", columns.Select(x => Quote(x.Text("parent_column")))) + ") REFERENCES " + Quote(c.Text("referenced_schema")) + "." + Quote(c.Text("referenced_table")) + " (" + string.Join(", ", columns.Select(x => Quote(x.Text("referenced_column")))) + ")"; + foreach (string action in new[] { "delete", "update" }) + if (c.Text(action + "_referential_action_desc") != null) sql += " ON " + action.ToUpperInvariant() + " " + c.Text(action + "_referential_action_desc").Replace('_', ' '); + if (c.Flag("is_not_for_replication")) sql += " NOT FOR REPLICATION"; + } + output.WriteLine(sql + ";"); + output.WriteLine("ALTER TABLE " + Qualified(table.Key) + (c.Flag("is_disabled") ? " NOCHECK" : " CHECK") + " CONSTRAINT " + Quote(c.Name) + ";"); + Batch(output); + } + private static void Comment(TextWriter output, MssqlObjectKey key, string column, string value) + { + if (value == null) return; + output.WriteLine("EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=" + Literal(value) + ", @level0type=N'SCHEMA', @level0name=" + Literal(key.Schema) + ", @level1type=N'TABLE', @level1name=" + Literal(key.Name) + (column == null ? "" : ", @level2type=N'COLUMN', @level2name=" + Literal(column)) + ";"); + Batch(output); + } + private static void TableOption(TextWriter output, MssqlTableDefinition table, string fact, string option) + { + if (table.Number(fact) != 0) output.WriteLine("EXEC sys.sp_tableoption @TableNamePattern=" + Literal(Qualified(table.Key)) + ", @OptionName=" + Literal(option) + ", @OptionValue=" + Literal(table.Text(fact) == "True" ? "1" : table.Text(fact) == "False" ? "0" : table.Text(fact)) + ";"); + } + // Read only the module header. Comments and quoted identifiers may contain keywords. + // Rewrite ALTER/CREATE OR ALTER to CREATE so an existing target cannot be overwritten. + // The catalog name wins over stale module text left by sp_rename. + private static string ViewDefinition(MssqlViewDefinition view) + { + string text = view.Definition; int position = 0; + string token = Token(text, ref position); + if (string.Equals(token, "CREATE", StringComparison.OrdinalIgnoreCase)) + { + token = Token(text, ref position); + if (string.Equals(token, "OR", StringComparison.OrdinalIgnoreCase)) + { + if (!string.Equals(Token(text, ref position), "ALTER", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid view header."); + token = Token(text, ref position); + } + } + else if (string.Equals(token, "ALTER", StringComparison.OrdinalIgnoreCase)) token = Token(text, ref position); + else throw new InvalidOperationException("Unsupported view module header: " + Qualified(view.Key)); + if (!string.Equals(token, "VIEW", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid view header."); + if (Token(text, ref position).Length == 0) throw new InvalidOperationException("Missing view name."); + int end = position; int look = position; + if (Token(text, ref look) == ".") { if (Token(text, ref look).Length == 0) throw new InvalidOperationException("Missing view name."); end = look; } + return "CREATE VIEW " + Qualified(view.Key) + text.Substring(end); + } + private static string Token(string text, ref int position) + { + while (position < text.Length) + { + if (char.IsWhiteSpace(text[position])) { position++; continue; } + if (position + 1 < text.Length && text.Substring(position, 2) == "--") + { while (position < text.Length && text[position] != '\n') position++; continue; } + if (position + 1 < text.Length && text.Substring(position, 2) == "/*") + { + position += 2; int depth = 1; + while (position < text.Length && depth != 0) + { + if (position + 1 < text.Length && text.Substring(position, 2) == "/*") { depth++; position += 2; } + else if (position + 1 < text.Length && text.Substring(position, 2) == "*/") { depth--; position += 2; } + else position++; + } + if (depth != 0) throw new InvalidOperationException("Unclosed view header comment."); + continue; + } + break; + } + int start = position; + if (position == text.Length) return ""; + char first = text[position++]; + if (first == '[' || first == '"') + { + char closing = first == '[' ? ']' : '"'; bool closed = false; + while (position < text.Length) + { + if (text[position++] != closing) continue; + if (position < text.Length && text[position] == closing) { position++; continue; } + closed = true; break; + } + if (!closed) throw new InvalidOperationException("Unclosed view identifier."); + } + else if (char.IsLetterOrDigit(first) || first == '_' || first == '@' || first == '#') + while (position < text.Length && (char.IsLetterOrDigit(text[position]) || "_@$#".IndexOf(text[position]) >= 0)) position++; + return text.Substring(start, position - start); + } + private static string Placement(MssqlIndexDefinition index) => index?.Text("data_space_name") == null ? "" : " ON " + Quote(index.Text("data_space_name")); + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + private static string OnOff(bool value) => value ? "ON" : "OFF"; + private static string Quote(string value) => MssqlExportMetadataReader.Quote(value); + private static string Qualified(MssqlObjectKey key) => MssqlExportMetadataReader.Qualified(key); + private static string Literal(string value) => "N'" + value.Replace("'", "''") + "'"; + private static void Batch(TextWriter output) { output.WriteLine("GO"); } + private static void Settings(TextWriter output) + { + output.WriteLine("SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON; SET ANSI_PADDING ON; SET ANSI_WARNINGS ON;"); + output.WriteLine("SET ARITHABORT ON; SET CONCAT_NULL_YIELDS_NULL ON; SET NUMERIC_ROUNDABORT OFF;"); + Batch(output); + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 8a02726..cbddfbb 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -363,6 +363,7 @@ + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index b112356..348607a 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Schema.cs b/tests/SmokeTests.MssqlExport.Schema.cs new file mode 100644 index 0000000..8eed94c --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Schema.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static Dictionary SchemaFacts(params object[] pairs) + { + var result = new Dictionary(); + for (int i = 0; i < pairs.Length; i += 2) result.Add((string)pairs[i], pairs[i + 1]); + return result; + } + private static MssqlExportRequest SchemaRequest(MssqlExportSchema schema, MssqlExportMode mode = MssqlExportMode.SchemaAndData) + { return new MssqlExportRequest(Guid.NewGuid(), "source_db", schema.Tables.Select(t => t.Key).ToArray(), mode, mode != MssqlExportMode.DataOnly && schema.Views.Count != 0, Path.Combine(Path.GetTempPath(), "schema.sql"), null, null); } + private static void Schema_RequiresWriter() + { Assert(typeof(MssqlObjectKey).Assembly.GetType("mySQLPunk.lib.MssqlExport.MssqlSchemaScriptWriter") != null, "Native two-stage schema writer must exist"); } + private static MssqlExportSchema SchemaFixture() + { + var key = new MssqlObjectKey(1, "sa'les]", "ord]ers"); + var columns = new[] { + new MssqlColumnDefinition(SchemaFacts("column_id",1,"name","id","type_name","decimal","precision",38,"scale",0,"is_identity",true,"seed_value","-5","increment_value","-2","identity_not_for_replication",true)), + new MssqlColumnDefinition(SchemaFacts("column_id",2,"name","name","type_name","varchar","max_length",80,"is_nullable",true,"collation_name","Latin1_General_100_BIN2","default_name","df'name","default_definition","('x')","comment","owner's column")), + new MssqlColumnDefinition(SchemaFacts("column_id",3,"name","amount","type_name","decimal","precision",38,"scale",10)), + new MssqlColumnDefinition(SchemaFacts("column_id",4,"name","offset","type_name","datetimeoffset","scale",7)), + new MssqlColumnDefinition(SchemaFacts("column_id",5,"name","calc","type_name","decimal","is_computed",true,"computed_definition","([amount]*(2))","is_persisted",true,"is_nullable",false)), + new MssqlColumnDefinition(SchemaFacts("column_id",6,"name","rv","type_name","timestamp","is_nullable",false)) }; + var ixcols = new[] { new MssqlNativeFacts(SchemaFacts("name","amount","key_ordinal",2,"index_column_id",2,"is_descending_key",true)), new MssqlNativeFacts(SchemaFacts("name","id","key_ordinal",1,"index_column_id",1)), new MssqlNativeFacts(SchemaFacts("name","name","key_ordinal",0,"index_column_id",3,"is_included_column",true)) }; + var indexes = new[] { + new MssqlIndexDefinition(SchemaFacts("index_id",1,"name","PK_orders","type",1,"is_primary_key",true,"is_unique",true,"allow_row_locks",true,"allow_page_locks",true),new[]{ixcols[1]}), + new MssqlIndexDefinition(SchemaFacts("index_id",2,"name","ix","type",2,"has_filter",true,"filter_definition","([amount]>(0))","fill_factor",80,"allow_row_locks",true,"allow_page_locks",false,"is_disabled",true),ixcols) }; + var constraints = new[] { + new MssqlConstraintDefinition(SchemaFacts("name","ck","kind","CHECK","definition","([amount]>=(0))","is_not_trusted",true),null), + new MssqlConstraintDefinition(SchemaFacts("name","fk","kind","FK","referenced_object_id",1,"referenced_schema",key.Schema,"referenced_table",key.Name,"delete_referential_action_desc","NO_ACTION","update_referential_action_desc","NO_ACTION","is_disabled",true,"is_not_trusted",true,"is_not_for_replication",true),new[]{new MssqlNativeFacts(SchemaFacts("constraint_column_id",1,"parent_column","id","referenced_column","id"))}) }; + var table = new MssqlTableDefinition(key,SchemaFacts("comment","owner's table","lock_escalation_desc","TABLE","text_in_row_limit",0,"large_value_types_out_of_row",false),columns,indexes,constraints); + var v1 = new MssqlViewDefinition(new MssqlObjectKey(2,"dbo","v1"),SchemaFacts("definition","CREATE VIEW [dbo].[v1] AS SELECT [id] FROM [sa'les]]].[ord]]ers]","uses_ansi_nulls",true,"uses_quoted_identifier",true)); + var v2 = new MssqlViewDefinition(new MssqlObjectKey(3,"dbo","v2"),SchemaFacts("definition","ALTER VIEW [dbo].[v2] AS SELECT * FROM [dbo].[v1]","uses_ansi_nulls",true,"uses_quoted_identifier",false)); + return new MssqlExportSchema(new[]{table},new[]{v2,v1},new[]{new MssqlDependency(3,2,null,null,"dbo","v1"),new MssqlDependency(2,1,null,null,key.Schema,key.Name)},1,null); + } + private static void Schema_WritesNativeBeforeAndAfter() + { + var schema=SchemaFixture(); var writer=new MssqlSchemaScriptWriter(); var before=new StringWriter(); var after=new StringWriter(); + writer.WriteBeforeData(schema,SchemaRequest(schema),before,CancellationToken.None); + writer.WriteAfterData(schema,SchemaRequest(schema),after,CancellationToken.None); + var first=before.ToString(); var last=after.ToString(); + AssertContains(first,"decimal(38,10)","Keep native precision"); + AssertContains(first,"datetimeoffset(7)","Keep temporal precision"); + AssertContains(first,"varchar(80) COLLATE Latin1_General_100_BIN2","Keep source text collation"); + AssertContains(first,"IDENTITY(-5,-2) NOT FOR REPLICATION","Keep descending identity definition"); + AssertContains(first,"[calc] AS ([amount]*(2)) PERSISTED NOT NULL","Keep computed definition"); + AssertContains(first,"[rv] rowversion NOT NULL","Regenerate rowversion"); + AssertContains(first,"CONSTRAINT [df'name] DEFAULT ('x')","Keep named defaults"); + AssertContains(first,"CREATE TABLE [sa'les]]].[ord]]ers]","Quote names independently"); + AssertNotContains(first,"sp_tableoption", "Default table options need no unsupported text-in-row call on tables without legacy LOBs"); + AssertNotContains(first,"FOREIGN KEY","FK belongs after data"); + AssertNotContains(first,"CREATE INDEX","Indexes belong after data"); + AssertNotContains(first+last,"USE [source_db]","Restore database selected by caller"); + AssertNotContains(first+last,"DROP TABLE","Never silently replace a target"); + AssertContains(last,"PRIMARY KEY CLUSTERED ([id] ASC)","Native primary key"); + AssertContains(last,"([id] ASC, [amount] DESC) INCLUDE ([name]) WHERE ([amount]>(0))","Index ordering include and filter"); + AssertContains(last,"FILLFACTOR = 80","Preserve index option"); + AssertContains(last,"WITH NOCHECK ADD CONSTRAINT [ck] CHECK","Preserve untrusted check"); + AssertContains(last,"NOCHECK CONSTRAINT [fk]","Preserve disabled FK"); + AssertContains(last,"owner''s column","Escape extended property values"); + AssertContains(last,"SET QUOTED_IDENTIFIER OFF;","Preserve view SET option"); + Assert(last.IndexOf("CREATE VIEW [dbo].[v1]",StringComparison.Ordinal)last.IndexOf("FOREIGN KEY",StringComparison.Ordinal),"Disable indexes only after dependent creation"); + AssertNotContains(first+last,"RESEED","Untouched empty identity retains original seed"); + } + private static void Schema_ModesCancellationAndFailures() + { + var schema=SchemaFixture(); var writer=new MssqlSchemaScriptWriter(); + var output=new StringWriter(); var request=SchemaRequest(schema,MssqlExportMode.DataOnly); + writer.WriteBeforeData(schema,request,output,CancellationToken.None); writer.WriteAfterData(schema,request,output,CancellationToken.None); + AssertEquals("",output.ToString(),"Data only has no DDL"); + output=new StringWriter(); request=SchemaRequest(schema,MssqlExportMode.SchemaOnly); + writer.WriteBeforeData(schema,request,output,CancellationToken.None);writer.WriteAfterData(schema,request,output,CancellationToken.None); + AssertContains(output.ToString(),"FOREIGN KEY","Schema only includes complete constraints"); + AssertThrows(()=>writer.WriteBeforeData(schema,request,new StringWriter(),new CancellationToken(true)),"Cancellation before writing"); + var badDependencies=new[]{new MssqlDependency(1,900,null,null,"dbo","unselected_function")}; + var bad=new MssqlExportSchema(schema.Tables,null,badDependencies,1,null); + AssertThrows(()=>writer.WriteBeforeData(bad,SchemaRequest(bad),new StringWriter(),CancellationToken.None),"Missing function/sequence/table dependencies fail"); + var cycle=new MssqlExportSchema(schema.Tables,schema.Views,new[]{new MssqlDependency(2,3,null,null,"dbo","v2"),new MssqlDependency(3,2,null,null,"dbo","v1")},1,null); + AssertThrows(()=>writer.WriteAfterData(cycle,SchemaRequest(cycle),new StringWriter(),CancellationToken.None),"View cycle fails"); + var hidden=new MssqlExportSchema(schema.Tables,new[]{new MssqlViewDefinition(new MssqlObjectKey(4,"dbo","hidden"),SchemaFacts())},null,1,null); + AssertThrows(()=>writer.WriteBeforeData(hidden,SchemaRequest(hidden),new StringWriter(),CancellationToken.None),"Invisible view fails before output"); + var trigger=new MssqlExportSchema(new[]{new MssqlTableDefinition(schema.Tables[0].Key,SchemaFacts("trigger_count",1),schema.Tables[0].Columns,null,null)},null,null,1,null); + AssertThrows(()=>writer.WriteBeforeData(trigger,SchemaRequest(trigger),new StringWriter(),CancellationToken.None),"Triggers cannot silently disappear"); + // Self-reference is already in fixture; a two-table FK cycle is valid because both are created before FK DDL. + var a=new MssqlObjectKey(10,"dbo","a"); var b=new MssqlObjectKey(11,"dbo","b"); + Func table=(x,y)=>new MssqlTableDefinition(x,SchemaFacts(),new[]{new MssqlColumnDefinition(SchemaFacts("name","id","column_id",1,"type_name","int"))},null,new[]{new MssqlConstraintDefinition(SchemaFacts("name","fk_"+x.Name,"kind","FK","referenced_object_id",y.ObjectId,"referenced_schema",y.Schema,"referenced_table",y.Name),new[]{new MssqlNativeFacts(SchemaFacts("parent_column","id","referenced_column","id","constraint_column_id",1))})}); + var circular=new MssqlExportSchema(new[]{table(a,b),table(b,a)},null,null,1,null);output=new StringWriter(); + writer.WriteBeforeData(circular,SchemaRequest(circular),output,CancellationToken.None);writer.WriteAfterData(circular,SchemaRequest(circular),output,CancellationToken.None); + Assert(output.ToString().LastIndexOf("CREATE TABLE",StringComparison.Ordinal)x.Code=="UnsupportedAnsiPadding"),"Cannot recreate legacy padding on current engines"); + } + var legacy=new MssqlExportSchema(new[]{new MssqlTableDefinition(key,SchemaFacts("uses_ansi_nulls",false),new[]{new MssqlColumnDefinition(SchemaFacts("name","id","type_name","int"))},null,null)},null,null,1,null); + Assert(new MssqlExportMetadataReader().Validate(legacy,SchemaRequest(legacy)).Any(x=>x.Code=="UnsupportedAnsiNulls"),"Cannot recreate legacy ANSI NULLS semantics"); + } + private static void Schema_RejectsUndocumentedIndexOption() + { + var schema=SchemaFixture(); var original=schema.Tables[0]; + var index=new MssqlIndexDefinition(SchemaFacts("index_id",2,"name","unsupported","type",2,"suppress_dup_key_messages",true),original.Indexes[0].Columns); + var candidate=new MssqlExportSchema(new[]{new MssqlTableDefinition(original.Key,SchemaFacts(),original.Columns,new[]{index},null)},null,null,1,null); + Assert(new MssqlExportMetadataReader().Validate(candidate,SchemaRequest(candidate)).Any(x=>x.Code=="UnsupportedIndex"),"Do not emit undocumented CREATE INDEX options"); + } + private static void Schema_NormalizesOnlyViewHeader() + { + var original=SchemaFixture(); + foreach (string prefix in new[]{"/* CREATE VIEW fake */ CREATE OR ALTER VIEW ", "-- ALTER VIEW fake\r\n ALTER /* nested /* comment */ fine */ VIEW ", "CREATE VIEW "}) + { + var view=new MssqlViewDefinition(new MssqlObjectKey(9,"sales]","renamed$(name)"),SchemaFacts("definition",prefix+"[old schema].[old]]name] AS SELECT 'ALTER VIEW unchanged' AS [text]","uses_ansi_nulls",true,"uses_quoted_identifier",true)); + var schema=new MssqlExportSchema(original.Tables,new[]{view},null,1,null);var output=new StringWriter(); + new MssqlSchemaScriptWriter().WriteAfterData(schema,SchemaRequest(schema),output,CancellationToken.None); + AssertContains(output.ToString(),"CREATE VIEW [sales]]].[renamed$(name)] AS SELECT 'ALTER VIEW unchanged' AS [text]","Normalize executable header and stale name, preserve body and sqlcmd -x identifier"); + AssertNotContains(output.ToString(),"CREATE OR ALTER VIEW","Restore must reject existing view"); + } + } +} diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index ba6fcdb..982c809 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -28,6 +28,12 @@ public static partial class SmokeTests public static int Main() { int passed = 0; + Run("MSSQL export schema view headers", Schema_NormalizesOnlyViewHeader, ref passed); + Run("MSSQL export schema index option gate", Schema_RejectsUndocumentedIndexOption, ref passed); + Run("MSSQL export schema legacy ANSI gates", Schema_RejectsLegacyAnsiSemantics, ref passed); + Run("MSSQL export schema native stages", Schema_WritesNativeBeforeAndAfter, ref passed); + Run("MSSQL export schema modes and failures", Schema_ModesCancellationAndFailures, ref passed); + Run("MSSQL export schema native writer", Schema_RequiresWriter, ref passed); Run("MSSQL export metadata mapping and validation", Metadata_MapsNativeFactsAndValidates, ref passed); Run("MSSQL export metadata startup cancellation", Metadata_CancelsBeforeAndDuringReaderStartup, ref passed); Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); From 6703cb232cf0387fd068d1673256340af3f87864 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:46:19 +0800 Subject: [PATCH 11/32] docs(mssql-export): record native schema validation --- docs/design/mssql-export/implementation-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index f24457d..e79a766 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -187,17 +187,17 @@ internal void WriteBeforeData(MssqlExportSchema schema, MssqlExportRequest reque internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest request, TextWriter output, CancellationToken cancellation); ``` -- [ ] **1. 寫失敗測試,group `schema`。** metadata fixture 含 native varchar/decimal(38,10)/datetimeoffset(7)、identity、computed persisted、rowversion、複合降冪索引/INCLUDE/filter、FK/CHECK trust 狀態、comment、多層 views。assert: +- [x] **1. 寫失敗測試,group `schema`。** metadata fixture 含 native varchar/decimal(38,10)/datetimeoffset(7)、identity、computed persisted、rowversion、複合降冪索引/INCLUDE/filter、FK/CHECK trust 狀態、comment、多層 views。assert: ```csharp AssertContains(sql, "decimal(38,10)", "Keep native precision"); AssertNotContains(sql, "USE [source_db]", "Restore target is chosen by caller"); Assert(sql.IndexOf("CREATE TABLE") < sql.IndexOf("FOREIGN KEY"), "Create referenced tables first"); AssertContains(sql, "GO", "View DDL has its own batch"); ``` -- [ ] **2. 跑 schema group 為紅。** 加 circular FK、未包含 view/function/sequence/trigger、不可見 definition 的失敗案例;不能生成成功卻遺漏物件。 -- [ ] **3. 實作前後兩階段 writer。** 前段 schema/table、原生欄位與 default/computed;後段 keys/indexes、FK/CHECK、comments、依賴排序 views。自參照/循环 FK 留資料後建立。資料來源不含 computed/rowversion 值,摘要說明 rowversion 重生。僅資料兩段皆不建立物件;僅結構仍產生完整兩段。型別/identifier 不經 DatabaseDumpService mapper。 -- [ ] **4. 明定 restore 語意與 identity 處理。** 結構模式預期空白/無衝突目標,存在物件應明確失敗,不產生 DROP DATABASE/TABLE;DataOnly 要求相容既有表。identity 的 seed/increment 與匯入值,以及空表、負 increment 的後續新增須能供 Task 4 資料段及 Task 10 驗證;依設計不承諾來源配號間隙或完整 last_value 歷史,避免「已插最大值」被誤當全部規則。 -- [ ] **5. 跑 schema group 為綠並提交。** SQL tokens 測試不是還原證據;native execution 留 Task 10。建議訊息 `feat(mssql-export): script native schema and ordered dependencies`。 +- [x] **2. 跑 schema group 為紅。** 加 circular FK、未包含 view/function/sequence/trigger、不可見 definition 的失敗案例;不能生成成功卻遺漏物件。 +- [x] **3. 實作前後兩階段 writer。** 前段 schema/table、原生欄位與 default/computed;後段 keys/indexes、FK/CHECK、comments、依賴排序 views。自參照/循环 FK 留資料後建立。資料來源不含 computed/rowversion 值,摘要說明 rowversion 重生。僅資料兩段皆不建立物件;僅結構仍產生完整兩段。型別/identifier 不經 DatabaseDumpService mapper。 +- [x] **4. 明定 restore 語意與 identity 處理。** 結構模式預期空白/無衝突目標,存在物件應明確失敗,不產生 DROP DATABASE/TABLE;DataOnly 要求相容既有表。identity 的 seed/increment 與匯入值,以及空表、負 increment 的後續新增須能供 Task 4 資料段及 Task 10 驗證;依設計不承諾來源配號間隙或完整 last_value 歷史,避免「已插最大值」被誤當全部規則。 +- [x] **5. 跑 schema group 為綠並提交。** SQL tokens 測試不是還原證據;native execution 留 Task 10。建議訊息 `feat(mssql-export): script native schema and ordered dependencies`。 ## Task 4:一般列串流、型別及批次預算 From f409c5a754d8ff50325f1abe37d3469af02bed9f Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 14:50:14 +0800 Subject: [PATCH 12/32] docs(mssql-export): record reviewed values and artifact integration --- .../mssql-export/implementation-plan.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index e79a766..7d1b71a 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -213,17 +213,17 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr Action onRowsAndSourceBytes, CancellationToken cancellation); ``` -- [ ] **1. 寫失敗測試,group `values`。** decimal 38 不走 CLR decimal、offset與7位小數、GUID、money、float roundtrip、NULL/empty、Unicode/換行/NUL/`GO`/`$(name)`。以 `SqlDecimal` fixture、InvariantCulture、受限 DbDataReader 測: +- [x] **1. 寫失敗測試,group `values`。** decimal 38 不走 CLR decimal、offset與7位小數、GUID、money、float roundtrip、NULL/empty、Unicode/換行/NUL/`GO`/`$(name)`。以 `SqlDecimal` fixture、InvariantCulture、受限 DbDataReader 測: ```csharp AssertEquals("[a]]b]", MssqlValueCodec.QuoteIdentifier("a]b"), "Quote each identifier"); AssertContains(decimalSql, "1234567890123456789012345678.1234567890", "Keep all 38 digits"); Assert(maxObservedStatementBytes <= 262144, "Bound statement bytes"); Assert(maxObservedBatchBytes <= 4194304, "Bound data batch bytes"); ``` -- [ ] **2. 跑 values group 為紅。** fake reader 對大值 GetString/GetValue、回頭讀 ordinal 立即 throw;偵測 row buffer 最大占用。不只測輸出相等,也測讀取方式。 -- [ ] **3. 實作 scalar codec 與串流 projection。** LOB 用 GetBytes/GetChars 小段;對 varchar/text 投影原始 varbinary 保留 code-page bytes,回復時指定來源 collation;Unicode 使用不丟失 UTF-16 code units 的編碼。文字/binary資料 literal使用typed hex expression,避免行首 GO/sqlcmd directive/控制字元穿透;text chunk 不切 surrogate pair。decimal 使用 SqlDecimal;不依當前文化格式化日期/數字。untyped XML 需串流投影及明確 XML 轉型/還原驗證,不能不小心 GetValue 整欄。 -- [ ] **4. 實作有限小列 INSERT 與計量。** 使用 source metadata 排除 computed/rowversion,identity 適當 ON/OFF。整列緩衝/encoded statement 接近預算時移交 Task 5,不回查來源。Task 5 完成前大列入口只能明確標為尚未實作並保持 UI 未接線,不能把暫時行為當正式支援上限。空表產生零筆INSERT;若無可插入欄位,以每來源列一個常數投影讀取並逐列INSERT DEFAULT VALUES,不把rowversion當持久值。 -- [ ] **5. 跑 values group 為綠,完成小資料 reader cases 後提交。** 建議訊息 `feat(mssql-export): stream typed row values within batch budgets`。 +- [x] **2. 跑 values group 為紅。** fake reader 對大值 GetString/GetValue、回頭讀 ordinal 立即 throw;偵測 row buffer 最大占用。不只測輸出相等,也測讀取方式。 +- [x] **3. 實作 scalar codec 與串流 projection。** LOB 用 GetBytes/GetChars 小段;對 varchar/text 投影原始 varbinary 保留 code-page bytes,回復時指定來源 collation;Unicode 使用不丟失 UTF-16 code units 的編碼。文字/binary資料 literal使用typed hex expression,避免行首 GO/sqlcmd directive/控制字元穿透;text chunk 不切 surrogate pair。decimal 使用 SqlDecimal;不依當前文化格式化日期/數字。untyped XML 需串流投影及明確 XML 轉型/還原驗證,不能不小心 GetValue 整欄。 +- [x] **4. 實作有限小列 INSERT 與計量。** 使用 source metadata 排除 computed/rowversion,identity 適當 ON/OFF。整列緩衝/encoded statement 接近預算時移交 Task 5,不回查來源。Task 5 完成前大列入口只能明確標為尚未實作並保持 UI 未接線,不能把暫時行為當正式支援上限。空表產生零筆INSERT;若無可插入欄位,以每來源列一個常數投影讀取並逐列INSERT DEFAULT VALUES,不把rowversion當持久值。 +- [x] **5. 跑 values group 為綠,完成小資料 reader cases 後提交。** 建議訊息 `feat(mssql-export): stream typed row values within batch budgets`。 ## Task 5:大欄位 staging、分段與失敗保護 @@ -275,7 +275,7 @@ internal string CopyVerified(string publishedPath, MssqlArtifactReceipt receipt, `MssqlArtifactReceipt` 定義 job ID、temp/published identity、long bytes、SHA-256、table/row totals、完成標記。完成標記不包含自己的 hash;receipt 保存包含標記在內的最終 hash。 -- [ ] **1. 寫失敗測試,group `artifact`。** 以全新 fixture 建「舊版」與「他人暫存檔」,注入 truncate/disk-full/rename失敗/cancel/同意後換檔: +- [x] **1. 寫失敗測試,group `artifact`。** 以全新 fixture 建「舊版」與「他人暫存檔」,注入 truncate/disk-full/rename失敗/cancel/同意後換檔: ```csharp Assert(File.ReadAllText(oldFixturePath) == "old fixture", "Failed publish keeps existing file"); Assert(File.Exists(otherJobTemp), "Cleanup owns only this job temp"); @@ -283,11 +283,11 @@ internal string CopyVerified(string publishedPath, MssqlArtifactReceipt receipt, Assert(streamReadMax <= 81920, "Verification uses fixed buffers"); ``` ReadAllText 只可用於這幾個 bytes 的測試哨兵檔;產品 verifier 及大檔測試禁用。 -- [ ] **2. 跑 artifact group 為紅。** 增加同目錄mirror no-op、mirror target已存在不覆蓋、cleanup失敗殘留路徑、發布後cancel結果仍有PublishedPath。 -- [ ] **3. 實作 temp/hash/驗證。** 目標同目錄 CreateNew,名稱為隨機 `.mysqlpunk-export-.partial`,不帶 `_backup_` 等 retention分類片段;UTF-8無BOM writer、incremental SHA-256與long計量、80KiB重讀。完成標記只在所有來源/DDL寫出成功才寫;關閉flush成功後才能verify。 -- [ ] **4. 實作發布邊界。** 正規路徑同一工作/process互斥,最後取消與destination stamp核對後進短不可取消區;不存在用不可覆寫Move,存在用File.Replace,保留可恢復位置。目標變更、鎖定、FS不支援replace均fail closed,不退回delete-then-move。以 Windows 實測核對外部競態;不得把process內互斥宣稱成所有程序的檔案鎖。清理只刪本工作明確owned暫存;已發布/可恢復檔不誤刪。 -- [ ] **5. 實作鏡像。** 串流copy到目的地獨有partial,驗證同receipt hash與長度,發布唯一正式名後才呼叫 `BackupRemoteMirrorService.PruneRemoteBackups(directory, retainCount)`。保留原分類規則,同時不可讓其他活躍工作partial被分類清掉;不呼叫舊MirrorBackup。錯誤/取消回worker以LocalCompletedMirrorIncomplete呈現。 -- [ ] **6. 跑 artifact group 為綠並提交。** 建議訊息 `feat(mssql-export): verify and publish exports and mirrors safely`。真UNC/權限/磁碟滿與發布競態仍列Task10。 +- [x] **2. 跑 artifact group 為紅。** 增加同目錄mirror no-op、mirror target已存在不覆蓋、cleanup失敗殘留路徑、發布後cancel結果仍有PublishedPath。 +- [x] **3. 實作 temp/hash/驗證。** 目標同目錄 CreateNew,名稱為隨機 `.mysqlpunk-export-.partial`,不帶 `_backup_` 等 retention分類片段;UTF-8無BOM writer、incremental SHA-256與long計量、80KiB重讀。完成標記只在所有來源/DDL寫出成功才寫;關閉flush成功後才能verify。 +- [x] **4. 實作發布邊界。** 正規路徑同一工作/process互斥,最後取消與destination stamp核對後進短不可取消區;不存在用不可覆寫Move,存在用File.Replace,保留可恢復位置。目標變更、鎖定、FS不支援replace均fail closed,不退回delete-then-move。以 Windows 實測核對外部競態;不得把process內互斥宣稱成所有程序的檔案鎖。清理只刪本工作明確owned暫存;已發布/可恢復檔不誤刪。 +- [x] **5. 實作鏡像。** 串流copy到目的地獨有partial,驗證同receipt hash與長度,發布唯一正式名後才呼叫 `BackupRemoteMirrorService.PruneRemoteBackups(directory, retainCount)`。保留原分類規則,同時不可讓其他活躍工作partial被分類清掉;不呼叫舊MirrorBackup。錯誤/取消回worker以LocalCompletedMirrorIncomplete呈現。 +- [x] **6. 跑 artifact group 為綠並提交。** 建議訊息 `feat(mssql-export): verify and publish exports and mirrors safely`。真UNC/權限/磁碟滿與發布競態仍列Task10。 ## Task 7:worker、進度/ETA 與取消 orchestration From 6aed738805638f1ada493748998a43827684c6da Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 15:09:17 +0800 Subject: [PATCH 13/32] feat(mssql-export): reconstruct large values with guarded SQL chunks --- .../lib/MssqlExport/MssqlDataScriptWriter.cs | 36 ++-- .../lib/MssqlExport/MssqlLobScriptWriter.cs | 161 ++++++++++++++++++ mySQLPunk/mySQLPunk.csproj | 1 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Lob.cs | 161 ++++++++++++++++++ tests/SmokeTests.MssqlExport.Values.cs | 4 +- tests/SmokeTests.cs | 3 + 7 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlLobScriptWriter.cs create mode 100644 tests/SmokeTests.MssqlExport.Lob.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs index 9c0d5de..309594d 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs @@ -8,12 +8,14 @@ namespace mySQLPunk.lib.MssqlExport { - // Task5 consumes this same reader synchronously, finishes exactly one row, and returns + // Consumes this same reader synchronously, finishes exactly one row, and returns // additional source bytes consumed. It must not Read(), reopen, rewind, or own the reader. + // The callback reports cumulative additional bytes after actual reads, excluding the + // retained prefix; WriteTable keeps completed rows unchanged until CommitRow succeeds. internal interface IMssqlLargeRowWriter { long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, - MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation); + MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation, Action onAdditionalBytes); } internal sealed class MssqlRowContinuation { @@ -50,9 +52,15 @@ internal MssqlScriptBatchWriter(TextWriter output) { this.output = output ?? throw new ArgumentNullException(nameof(output)); encoding = output is StreamWriter ? output.Encoding : new UTF8Encoding(false, true); } internal int Bytes(string text) { return encoding.GetByteCount(text); } internal void WriteStatement(string statement) + { WriteBoundedStatement(statement, MssqlExportLimits.StatementBytes); } + // Only LOB setup/final INSERT metadata may use this path: never inline row values. + // 1024 columns with 128-character escaped Unicode identifiers plus guards fit 2 MiB. + internal void WriteMetadataStatement(string statement) + { WriteBoundedStatement(statement, 2 * 1024 * 1024); } + private void WriteBoundedStatement(string statement, int limit) { int bytes = Bytes(statement) + Bytes("\r\n"); - if (bytes > MssqlExportLimits.StatementBytes) throw new InvalidOperationException("Statement exceeds the bounded script budget."); + if (bytes > limit) throw new InvalidOperationException("Statement exceeds the bounded script budget."); if (batchBytes + bytes + Bytes("GO\r\n") > MssqlExportLimits.BatchBytes) Flush(); output.Write(statement); output.Write("\r\n"); batchBytes += bytes; } @@ -73,7 +81,9 @@ internal bool TryWriteRow(string header, char[] values, int[] starts, int[] leng internal sealed class MssqlDataScriptWriter { private readonly IMssqlLargeRowWriter largeRows; - internal MssqlDataScriptWriter(IMssqlLargeRowWriter largeRows = null) { this.largeRows = largeRows; } + // A worker reuses new MssqlDataScriptWriter(new MssqlLobScriptWriter(request.JobId)) + // across every table in one output. The default also supports large values safely. + internal MssqlDataScriptWriter(IMssqlLargeRowWriter largeRows = null) { this.largeRows = largeRows ?? new MssqlLobScriptWriter(Guid.NewGuid()); } internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWriter output, Action onRowsAndSourceBytes, CancellationToken cancellation) { @@ -96,7 +106,12 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr cancellation.ThrowIfCancellationRequested(); if (identity && !identityOn) { batches.WriteStatement("SET IDENTITY_INSERT " + target + " ON;"); identityOn = true; } int used = 0; bool handedOff = false; - for (int i = 0; i < columns.Count; i++) + if (columns.Count > 0 && batches.Bytes(header) + 4 > MssqlExportLimits.StatementBytes) + { + sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, 0, 0, false, false), batches, cancellation, rows, sourceBytes, onRowsAndSourceBytes); + handedOff = true; + } + for (int i = 0; i < columns.Count && !handedOff; i++) { cancellation.ThrowIfCancellationRequested(); starts[i] = used; lengths[i] = 0; @@ -105,7 +120,7 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr // Fixed-width scalar expressions are bounded (<100 chars). Do not consume // the next column when there is insufficient room; Task5 starts it itself. if (capacity - used < 128) - { sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, 0, false, false), batches, cancellation); handedOff = true; break; } + { sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, 0, false, false), batches, cancellation, rows, sourceBytes, onRowsAndSourceBytes); handedOff = true; break; } if (reader.IsDBNull(i)) { Append(buffer, ref used, "NULL"); lengths[i] = used - starts[i]; continue; } if (!binary) { @@ -122,7 +137,7 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr if (count == 0) { lengths[i] = used - starts[i]; - sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, offset, true, false), batches, cancellation); handedOff = true; break; + sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, i, offset, true, false), batches, cancellation, rows, sourceBytes, onRowsAndSourceBytes); handedOff = true; break; } int read = checked((int)reader.GetBytes(i, offset, chunk, 0, count)); if (read == 0) { Append(buffer, ref used, suffix); break; } @@ -137,7 +152,7 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr if (!batches.TryWriteRow(header, buffer, starts, lengths)) { int last = columns.Count - 1; - sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, last, 0, true, true), batches, cancellation); + sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, last, 0, true, true), batches, cancellation, rows, sourceBytes, onRowsAndSourceBytes); } } rows++; onRowsAndSourceBytes?.Invoke(rows, sourceBytes); @@ -147,10 +162,9 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr batches.Flush(); return rows; } - private long Handoff(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation) + private long Handoff(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation, long completedRows, long priorBytes, Action onProgress) { - if (largeRows == null) throw new NotImplementedException("Large row continuation requires the Task5 streaming staging writer; the export UI is not connected yet."); - return largeRows.WriteRow(reader, table, columns, row, output, cancellation); + return largeRows.WriteRow(reader, table, columns, row, output, cancellation, added => onProgress?.Invoke(completedRows, priorBytes + added)); } private static void Append(char[] buffer, ref int used, string value) { value.CopyTo(0, buffer, used, value.Length); used += value.Length; } diff --git a/mySQLPunk/lib/MssqlExport/MssqlLobScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlLobScriptWriter.cs new file mode 100644 index 0000000..a03a04c --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlLobScriptWriter.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + // One instance per export job, reused across tables. Projection ordinals are zero-based; + // the monotonically increasing protocol sequence is independent of source row/column IDs. + internal sealed class MssqlLobScriptWriter : IMssqlLargeRowWriter + { + private readonly string state, values, fields; + private long sequence; + private MssqlTableDefinition table; + private IReadOnlyList columns; + private string Identity { get { return "[Sequence]=" + N(sequence) + " AND [TableId]=" + N(table.Key.ObjectId); } } + internal MssqlLobScriptWriter(Guid jobId) + { + if(jobId==Guid.Empty) throw new ArgumentException("A job identity is required.",nameof(jobId)); + string root="#mp_"+jobId.ToString("N");state=root+"_s";values=root+"_v";fields=root+"_c"; + } + public long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, + MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation, Action onAdditionalBytes) + { + cancellation.ThrowIfCancellationRequested(); + BeginRow(table,columns,output); + long additional=0; + for(int i=0;i=2 && buffer[emit-1]>=0xD8 && buffer[emit-1]<=0xDB)emit-=2; + } + if(emit==0)throw new InvalidOperationException("Cannot advance LOB chunk."); + WriteChunk(i,index++,buffer,emit,output);total+=emit; + Buffer.BlockCopy(buffer,emit,buffer,0,used-emit);used-=emit; + } + CompleteColumn(i,false,index,total,output); + } + cancellation.ThrowIfCancellationRequested();CommitRow(output);return additional; + } + internal void BeginRow(MssqlTableDefinition table,IReadOnlyList columns,MssqlScriptBatchWriter output) + { + this.table=table;this.columns=columns;sequence=checked(sequence+1); + output.Flush(); + if(sequence==1) { + output.WriteStatement("IF OBJECT_ID('tempdb.."+state+"') IS NOT NULL THROW 51000,'LOB job already exists',1; CREATE TABLE "+state+" ([Sequence] bigint NOT NULL,[TableId] int NOT NULL,[ConsumedThrough] bigint NOT NULL,[Consumed] bit NOT NULL,[Failed] bit NOT NULL); INSERT INTO "+state+" VALUES(0,0,0,1,0);");output.Flush(); + } + string definition=string.Join(",",columns.Select((c,i)=>Field(i)+" "+StageType(c)+" NULL")); + string initial=string.Join(",",columns.Select(c=>MssqlValueCodec.IsBinaryProjected(c) ? (Unicode(c) ? "CONVERT(nvarchar(max),0x)" : "0x") : "NULL")); + // Compile the initial INSERT after CREATE: the reusable temp table may still + // have the previous table's shape when this batch is first compiled. Only + // generated empty/NULL constants enter EXEC, never source values or SQL names. + string body="IF NOT EXISTS(SELECT 1 FROM "+state+" WHERE [Failed]=0 AND [Consumed]=1 AND [ConsumedThrough]="+N(sequence-1)+") THROW 51000,'LOB prior row not consumed',1; "+ + "UPDATE "+state+" SET [Sequence]="+N(sequence)+",[TableId]="+N(table.Key.ObjectId)+",[Consumed]=0; "+ + "IF OBJECT_ID('tempdb.."+values+"') IS NOT NULL DROP TABLE "+values+"; IF OBJECT_ID('tempdb.."+fields+"') IS NOT NULL DROP TABLE "+fields+"; "+ + "CREATE TABLE "+values+" ("+definition+"); EXEC(N'INSERT INTO "+values+" VALUES("+initial+");'); "+ + "CREATE TABLE "+fields+" ([Ordinal] int NOT NULL PRIMARY KEY,[Chunks] bigint NOT NULL,[Bytes] bigint NOT NULL,[ExpectedChunks] bigint NULL,[ExpectedBytes] bigint NULL,[IsNull] bit NOT NULL,[Completed] bit NOT NULL); "+ + string.Join(" ",columns.Select((c,i)=>"INSERT INTO "+fields+" VALUES("+N(i)+",0,0,NULL,NULL,0,0);")); + Guarded(body,output,false,true); + } + internal void WriteScalar(int ordinal,string scalarExpression,MssqlScriptBatchWriter output) + { + Guarded(OpenField(ordinal)+"UPDATE "+values+" SET "+Field(ordinal)+"="+scalarExpression+"; UPDATE "+fields+" SET [Completed]=1,[IsNull]="+(scalarExpression=="NULL" ? "1" : "0")+",[ExpectedChunks]=0,[ExpectedBytes]=COALESCE((SELECT CONVERT(bigint,DATALENGTH("+Field(ordinal)+")) FROM "+values+"),0),[Bytes]=COALESCE((SELECT CONVERT(bigint,DATALENGTH("+Field(ordinal)+")) FROM "+values+"),0) WHERE [Ordinal]="+N(ordinal)+";",output); + } + internal void WriteChunk(int ordinal,long chunkIndex,byte[] encodedBytes,int count,MssqlScriptBatchWriter output) + { + if(count<=0 || count>encodedBytes.Length || count>MssqlExportLimits.BinaryChunkBytes || (Unicode(columns[ordinal]) && ((count&1)!=0 || count>MssqlExportLimits.TextChunkCodeUnits*2)))throw new ArgumentOutOfRangeException(nameof(count)); + var hex=new StringBuilder(2+count*2);hex.Append("0x");for(int j=0;j0 OR "+N(expectedBytes)+"<>0 THROW 51000,'LOB null totals',1; UPDATE "+values+" SET "+Field(ordinal)+"=NULL; " : LengthGuard(ordinal))+ + "UPDATE "+fields+" SET [Completed]=1,[IsNull]="+(isNull ? "1" : "0")+",[ExpectedChunks]="+N(chunkCount)+",[ExpectedBytes]="+N(expectedBytes)+" WHERE [Ordinal]="+N(ordinal)+";",output); + } + internal void CommitRow(MssqlScriptBatchWriter output) + { + string checks="IF (SELECT COUNT(*) FROM "+fields+")<>"+N(columns.Count)+" OR EXISTS(SELECT 1 FROM "+fields+" WHERE [Completed]<>1 OR [ExpectedChunks] IS NULL OR [ExpectedBytes] IS NULL OR [Chunks]<>[ExpectedChunks] OR [Bytes]<>[ExpectedBytes]) THROW 51000,'LOB incomplete row',1; "; + string lengths=string.Join(",",columns.Select((c,i)=>"("+N(i)+",CONVERT(bigint,DATALENGTH("+Field(i)+")))")); + checks+="IF EXISTS(SELECT 1 FROM "+values+" CROSS APPLY(VALUES "+lengths+") AS actual([Ordinal],[Bytes]) LEFT JOIN "+fields+" AS expected ON actual.[Ordinal]=expected.[Ordinal] WHERE expected.[Ordinal] IS NULL OR COALESCE(actual.[Bytes],0)<>expected.[Bytes] OR expected.[IsNull]<>CASE WHEN actual.[Bytes] IS NULL THEN 1 ELSE 0 END) THROW 51000,'LOB final length or null mismatch',1; "; + string target=MssqlValueCodec.QuoteIdentifier(table.Key.Schema)+"."+MssqlValueCodec.QuoteIdentifier(table.Key.Name); + Guarded(checks+"INSERT INTO "+target+" ("+string.Join(",",columns.Select(c=>MssqlValueCodec.QuoteIdentifier(c.Name)))+") SELECT "+string.Join(",",columns.Select((c,i)=>c.NativeType=="xml" ? "CONVERT(xml,"+Field(i)+",1)" : Field(i)))+" FROM "+values+"; "+ + "UPDATE "+state+" SET [Consumed]=1,[ConsumedThrough]="+N(sequence)+"; DELETE FROM "+values+"; DELETE FROM "+fields+";",output,true,true); + } + private string OpenField(int ordinal) + { return "IF NOT EXISTS(SELECT 1 FROM "+fields+" WHERE [Ordinal]="+N(ordinal)+" AND [Completed]=0) THROW 51000,'LOB column completed or missing',1; "; } + private string LengthGuard(int ordinal) + { return "IF NOT EXISTS(SELECT 1 FROM "+fields+" WHERE [Ordinal]="+N(ordinal)+" AND [Bytes]=COALESCE((SELECT CONVERT(bigint,DATALENGTH("+Field(ordinal)+")) FROM "+values+"),0)) THROW 51000,'LOB byte length',1; "; } + private void Guarded(string body,MssqlScriptBatchWriter output,bool active=true,bool metadataOnly=false) + { + string check=active ? "IF NOT EXISTS(SELECT 1 FROM "+state+" WHERE "+Identity+" AND [Failed]=0 AND [Consumed]=0 AND [ConsumedThrough]="+N(sequence-1)+") THROW 51000,'LOB inactive or consumed row',1; IF (SELECT COUNT(*) FROM "+values+")<>1 THROW 51000,'LOB missing stage',1; " : ""; + string batch="BEGIN TRY BEGIN TRANSACTION; "+check+body+" COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE()<>0 ROLLBACK TRANSACTION; UPDATE "+state+" SET [Failed]=1; THROW; END CATCH;"; + if(metadataOnly)output.WriteMetadataStatement(batch);else output.WriteStatement(batch); + // Each short transaction is a self-contained batch. Failed clients that continue at + // the next GO encounter the durable Failed bit before any further target mutation. + output.Flush(); + } + private static string StageType(MssqlColumnDefinition c) + { + if(Unicode(c))return "nvarchar(max)"; + if(c.NativeType=="varchar" || c.NativeType=="char" || c.NativeType=="text") { + if(string.IsNullOrEmpty(c.Collation) || c.Collation.Any(ch=>!(char.IsLetterOrDigit(ch) && ch<128) && ch!='_'))throw new InvalidOperationException("Native ANSI collation is required."); + return "varchar(max) COLLATE "+c.Collation; + } + if(MssqlValueCodec.IsBinaryProjected(c))return "varbinary(max)"; + switch(c.NativeType) { + case "decimal":case "numeric":return c.NativeType+"("+N(c.Precision)+","+N(c.Scale)+")"; + case "time":case "datetime2":case "datetimeoffset":return c.NativeType+"("+N(c.Scale)+")"; + case "bit":case "tinyint":case "smallint":case "int":case "bigint":case "money":case "smallmoney":case "real":case "float":case "date":case "datetime":case "smalldatetime":case "uniqueidentifier":return c.NativeType; + default:throw new NotSupportedException("No LOB staging type for "+c.NativeType); + } + } + private static bool Unicode(MssqlColumnDefinition c) { return c.NativeType=="nvarchar" || c.NativeType=="nchar" || c.NativeType=="ntext" || c.NativeType=="xml"; } + private static bool IsNull(MssqlRowContinuation row,int ordinal) { if(row.ValueLength(ordinal)!=4)return false;var b=new char[4];row.CopyValueChars(ordinal,0,b,0,4);return new string(b)=="NULL"; } + private static int Nibble(char c) { return c<='9' ? c-'0' : c-'A'+10; } + private static string Field(int ordinal) { return "[c"+N(ordinal)+"]"; } + private static string N(long value) { return value.ToString(CultureInfo.InvariantCulture); } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 90b5674..24aee0c 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -368,6 +368,7 @@ + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index bff6d11..6dd9b8e 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Lob.cs b/tests/SmokeTests.MssqlExport.Lob.cs new file mode 100644 index 0000000..b945755 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Lob.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + // Regressions: default missing implementation, backward handoff reads, duplicate/lost + // prefix bytes, unbounded chunks, and premature target INSERT. + private static void Lob_LazyLargeRow() + { + var lengths = new long[] { 64L*1024*1024, 18L*1024*1024+6, 0, -1 }; + using(var reader = new LobReader(lengths)) using(var sink = new LobSink(true)) { + long source=0; int during=0; + var table=ValueTable(ValueColumn("image",2,"b"),ValueColumn("ntext",7,"u"),ValueColumn("varbinary",11,"empty"),ValueColumn("nvarchar",19,"nil")); + Assert(new MssqlDataScriptWriter().WriteTable(reader,table,sink,(r,b)=>{Assert(b>=source,"Monotonic source progress");source=b;if(r==0 && b>0 && b10,"Progress while the first large row is still incomplete"); + Assert(source==lengths[0]+lengths[1],"Exact source-byte count across handoff"); + Assert(reader.ReadCalls==2 && reader.TotalBytes==source,"One sequential source pass"); + Assert(sink.Inserts==1 && sink.MaxStatement<=262144 && sink.MaxBatch<=4194304,"Bounded statements and exactly one target insert"); + Assert(sink.BinaryBytes==lengths[0] && sink.UnicodeBytes==lengths[1],"Every retained and new byte emitted exactly once"); + } + } + private static void Lob_ContinuationBranches() + { + var adapter=new LobContinuationObserver(); + using(var reader=new ValueReader(new[]{new object[]{new byte[63460],9}})) using(var sink=new LobSink()) { + new MssqlDataScriptWriter(adapter).WriteTable(reader,ValueTable(ValueColumn("varbinary"),ValueColumn("int",7,"tail")),sink,null,CancellationToken.None); + Assert(adapter.Untouched && sink.Inserts==1 && sink.BinaryBytes==63460,"Untouched current field is consumed once"); + } + // SQL Server permits 1024 columns and 128 Unicode characters per identifier. + // The legal column list alone exceeds the data statement limit; values stay staged. + foreach(int count in new[]{512,1024}) { + var columns=Enumerable.Range(0,count).Select(i=>ValueColumn(i==0 ? "varbinary" : "int",i*2+1,new string('\u4e2d',123)+i.ToString("D4"))).ToArray(); + var data=Enumerable.Range(0,count).Select(i=>i==0 ? (object)new byte[40000] : i).ToArray(); + adapter=new LobContinuationObserver(); + using(var reader=new ValueReader(new[]{data})) using(var sink=new LobSink()) { + new MssqlDataScriptWriter(adapter).WriteTable(reader,ValueTable(columns),sink,null,CancellationToken.None); + Assert((count==512 ? adapter.Completed : adapter.Untouched) && sink.Inserts==1 && sink.BinaryBytes==40000,"Completed or oversized header handoff never rereads source"); + Assert(sink.MaxBatch<=4194304,"Metadata-only insert fits bounded batch"); + } + } + } + private sealed class LobContinuationObserver : IMssqlLargeRowWriter + { + private readonly MssqlLobScriptWriter writer=new MssqlLobScriptWriter(Guid.NewGuid()); + internal bool Untouched,Completed; + public long WriteRow(DbDataReader reader,MssqlTableDefinition table,IReadOnlyList columns,MssqlRowContinuation row,MssqlScriptBatchWriter output,CancellationToken cancellation,Action onAdditionalBytes) { + Untouched=!row.CurrentValueStarted;Completed=row.CurrentValueComplete; + Assert(row.BufferBytes<=262144,"Small row buffer bounded"); + return writer.WriteRow(reader,table,columns,row,output,cancellation,onAdditionalBytes); + } + } + private static void Lob_Utf16AndCancel() + { + using(var reader=new LobReader(new long[]{2000078})) using(var sink=new LobSink(true)) { + new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("nvarchar")),sink,null,CancellationToken.None); + Assert(sink.UnicodeBytes==2000078 && sink.Inserts==1,"Partial UTF16 handoff has no data loss"); + } + using(var cancel=new CancellationTokenSource()) using(var reader=new LobReader(new long[]{64L*1024*1024},cancel)) using(var sink=new LobSink()) { + AssertThrows(()=>new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varbinary")),sink,null,cancel.Token),"Cancel stops streaming current LOB"); + Assert(sink.Inserts==0 && reader.TotalBytes<64L*1024*1024,"Canceled row cannot be inserted"); + } + } + // Keep only one bounded generated statement, never the large SQL document. + private sealed class LobSink : TextWriter + { + private readonly StringBuilder line=new StringBuilder(); private int batch; + private readonly bool verifyPattern; private readonly Dictionary offsets=new Dictionary(); + private readonly Dictionary lastUnit=new Dictionary(); + internal LobSink(bool verifyPattern=false){this.verifyPattern=verifyPattern;} + internal long BinaryBytes,UnicodeBytes; internal int Inserts,MaxStatement,MaxBatch; + public override Encoding Encoding { get { return new UTF8Encoding(false,true); } } + public override void Write(char value) { if(value=='\n') { Consume(line.ToString().TrimEnd('\r'));line.Clear(); } else line.Append(value); } + public override void Write(string value) { foreach(char c in value) Write(c); } + public override void Write(char[] value,int start,int count) { for(int i=0;i=count-2)last|=value<<((j-(count-2))*8); + } + int prior; + if(unicode && lastUnit.TryGetValue(field,out prior))Assert(!(prior>=0xd800 && prior<=0xdbff && first>=0xdc00 && first<=0xdfff),"No split surrogate pair"); + lastUnit[field]=last;offsets[field]=offset+count; + } + } + private static int HexDigit(char value){return value<='9' ? value-'0' : value-'A'+10;} + } + private sealed class LobReader : DbDataReader + { + private readonly long[] lengths; private readonly CancellationTokenSource cancel; + private int ordinal=-1; private long offset; internal long TotalBytes; internal int ReadCalls; + internal LobReader(long[] lengths,CancellationTokenSource cancel=null){this.lengths=lengths;this.cancel=cancel;} + private void At(int i){if(iordinal){ordinal=i;offset=0;}} + public override bool Read(){ReadCalls++;ordinal=-1;offset=0;return ReadCalls==1;} + public override bool IsDBNull(int i){At(i);return lengths[i]<0;} + public override long GetBytes(int i,long start,byte[] buffer,int index,int count){ + At(i);if(buffer==null || start!=offset || count>65536)throw new InvalidOperationException("Nonsequential or unbounded read"); + int n=(int)Math.Min(count,lengths[i]-start); + // Repeated valid surrogate pair, NUL, control, spaces, then unpaired high surrogate. + byte[] pattern={0x3d,0xd8,0x00,0xde,0,0,0x0a,0,0x20,0,0,0xd8,0x41,0}; + for(int j=0;j200000)cancel.Cancel();return n; + } + public override object GetValue(int i){throw new InvalidOperationException("Whole LOB");} + public override string GetString(int i){throw new InvalidOperationException("Whole string");} + public override long GetChars(int i,long a,char[] b,int c,int d){throw new NotSupportedException();} + public override int GetInt32(int i){throw new NotSupportedException();} + public override bool GetBoolean(int i){throw new NotSupportedException();} + public override byte GetByte(int i){throw new NotSupportedException();} + public override short GetInt16(int i){throw new NotSupportedException();} + public override long GetInt64(int i){throw new NotSupportedException();} + public override decimal GetDecimal(int i){throw new NotSupportedException();} + public override float GetFloat(int i){throw new NotSupportedException();} + public override double GetDouble(int i){throw new NotSupportedException();} + public override Guid GetGuid(int i){throw new NotSupportedException();} + public override DateTime GetDateTime(int i){throw new NotSupportedException();} + public override char GetChar(int i){throw new NotSupportedException();} + public override int FieldCount{get{return lengths.Length;}} + public override bool HasRows{get{return true;}} + public override bool IsClosed{get{return false;}} + public override int RecordsAffected{get{return -1;}} + public override int Depth{get{return 0;}} + public override object this[int i]{get{return GetValue(i);}} + public override object this[string name]{get{throw new NotSupportedException();}} + public override string GetName(int i){throw new NotSupportedException();} + public override int GetOrdinal(string name){throw new NotSupportedException();} + public override string GetDataTypeName(int i){throw new NotSupportedException();} + public override Type GetFieldType(int i){throw new NotSupportedException();} + public override int GetValues(object[] values){throw new NotSupportedException();} + public override IEnumerator GetEnumerator(){throw new NotSupportedException();} + public override bool NextResult(){return false;} + } +} diff --git a/tests/SmokeTests.MssqlExport.Values.cs b/tests/SmokeTests.MssqlExport.Values.cs index 47cf1a4..bb3271e 100644 --- a/tests/SmokeTests.MssqlExport.Values.cs +++ b/tests/SmokeTests.MssqlExport.Values.cs @@ -74,7 +74,7 @@ private static void Values_BudgetsAndHandoff() Assert(sink.Bytes==300000 && sink.PrefixValue=="5" && sink.Tail==9,"Handoff consumes retained prefix and forward-only remainder"); } using(var reader=new ValueReader(new[]{new object[]{new byte[300000]}})) using(var output=new StringWriter()) - AssertThrows(()=>new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varbinary")),output,null,CancellationToken.None),"Task5 absent explicitly"); + Assert(new MssqlDataScriptWriter().WriteTable(reader,ValueTable(ValueColumn("varbinary")),output,null,CancellationToken.None)==1,"Default large support"); } private static void Values_DefaultAndCancel() { @@ -93,7 +93,7 @@ private static void Values_DefaultAndCancel() private sealed class ValueContinuationSink : IMssqlLargeRowWriter { internal long Bytes; internal string PrefixValue; internal int Tail; - public long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation) { + public long WriteRow(DbDataReader reader, MssqlTableDefinition table, IReadOnlyList columns, MssqlRowContinuation row, MssqlScriptBatchWriter output, CancellationToken cancellation, Action onAdditionalBytes) { var prefix=new char[row.ValueLength(0)]; row.CopyValueChars(0,0,prefix,0,prefix.Length); PrefixValue=new string(prefix); Bytes=row.CurrentByteOffset; Assert(row.BufferBytes<=262144,"Bounded retained row buffer"); var bytes=new byte[4096]; int n; diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index f977961..ff04099 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -38,6 +38,9 @@ public static int Main() Run("MSSQL export metadata startup cancellation", Metadata_CancelsBeforeAndDuringReaderStartup, ref passed); Run("MSSQL export metadata reader ownership", Metadata_DisposesReaderAndCancellationBeforeCommand, ref passed); Run("MSSQL export metadata native reader", Metadata_RequiresNativeReader, ref passed); + Run("MSSQL export lob continuation branches", Lob_ContinuationBranches, ref passed); + Run("MSSQL export lob lazy large row", Lob_LazyLargeRow, ref passed); + Run("MSSQL export lob UTF16 and cancellation", Lob_Utf16AndCancel, ref passed); Run("MSSQL export values scalars", Values_Scalars, ref passed); Run("MSSQL export values projection and streaming", Values_ProjectionAndStreaming, ref passed); Run("MSSQL export values budgets and handoff", Values_BudgetsAndHandoff, ref passed); From 57e7349d239ad9f2da40021d65f2c54d31e609b3 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 15:12:28 +0800 Subject: [PATCH 14/32] docs(mssql-export): record reviewed large-value restoration --- docs/design/mssql-export/design.md | 6 +++--- docs/design/mssql-export/implementation-plan.md | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/design/mssql-export/design.md b/docs/design/mssql-export/design.md index 1c611ca..d996400 100644 --- a/docs/design/mssql-export/design.md +++ b/docs/design/mssql-export/design.md @@ -2,7 +2,7 @@ 日期:2026-10-12 -狀態:互動畫面及本份修訂規格已獲使用者同意;2026-10-12 使用者回覆「照修訂版進入實作計畫(建議)」。[實作計畫](implementation-plan.md) 已整理,待審閱與選擇執行方式。尚未實作產品匯出引擎。 +狀態:互動畫面、修訂規格與[實作計畫](implementation-plan.md) 已獲使用者同意;2026-10-12 使用者選擇「同意計畫,分工實作並逐項覆核(建議)」。產品實作與隔離驗證進行中;目前證據以 implementation-plan.md 的完成標記與後續 verification.md 為準。 2026-10-12 修訂:使用者要求調整單筆大欄位限制,關心 geometry/長文字,並接受極端型別後續再擴充。取消原草案「單列 SQL 超過 16 MiB 就拒絕」規則;一般長文字/binary 改為可分段輸出,原生 spatial 型別仍列後續擴充。型別可容納的大小是設計目標,不代表所有理論上限已完成實機驗收。 @@ -127,7 +127,7 @@ SQL Server 的 batch 長度受 network packet size 影響,總檔案可達數 G 7. 匯入工具必須全檔維持同一 session,支援 GO,錯誤時停止,且不展開來源文字中的 sqlcmd `$(...)`。驗收使用單次 `sqlcmd -i`、`-b`、`-x` 及明確 UTF-8 輸入設定;不把必須同 session 的脚本分成多次連線執行。正式交付文件列出經驗證的實際參數;GUI 匯入只有通過同樣測試後才宣稱支援。 8. 每列 INSERT 完就清掉該列 staging,不累積整張表。匯出端維持有界記憶體;還原端仍需足夠 tempdb/交易記錄與目標儲存空間容納當前大列,不能承諾無限資源。空間不足時,該列不得以殘缺值發布。 -以上是設計方案,尚未執行 SQL 驗證。文件依據:[Microsoft UPDATE .WRITE](https://learn.microsoft.com/en-us/sql/t-sql/queries/update-transact-sql?view=sql-server-ver17)、[GO 與 batch 範圍](https://learn.microsoft.com/en-us/sql/t-sql/language-elements/sql-server-utilities-statements-go?view=sql-server-ver17)。 +以下為設計依據;實際 SQL 驗證結果另記於交付證據,不以本節規格取代:[Microsoft UPDATE .WRITE](https://learn.microsoft.com/en-us/sql/t-sql/queries/update-transact-sql?view=sql-server-ver17)、[GO 與 batch 範圍](https://learn.microsoft.com/en-us/sql/t-sql/language-elements/sql-server-utilities-statements-go?view=sql-server-ver17)。 ### 空間資料與後續擴充 @@ -214,7 +214,7 @@ Idle → Preflight → Reading/Writing → Verifying → Publishing → Mirrorin ## 11. 交付與流程狀態 -- 本次已交付獨立互動原型;真實匯出程式與 DB 驗收未完成。 +- 已交付獨立互動原型;真實匯出程式與隔離 DB 驗證進行中,尚未完成整體交付。 - 本文件已獲同意,具體實作步驟與測試安排見 [implementation-plan.md](implementation-plan.md)。規格核准不擴張為 merge、push 或 release 授權。 - 舊版入口相容、metadata/SQL writer、worker/取消/發布、精靈與進度、實機 fixture 是同一功能的必要工作,不能只接好畫面就宣布完成。 - 開發過程保留既有檔案 BOM/換行、未相關工作樹內容;重要結果追加 `history.md`。 diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index 7d1b71a..5b376a0 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -241,7 +241,7 @@ internal void CommitRow(TextWriter output); `encodedBytes` 是 codec 指定的原生 binary/varchar bytes/UTF-16 bytes;chunk writer 不保留呼叫者 buffer。型別、collation、欄位列表由 BeginRow 固定。小型非LOB值透過codec生成的scalarExpression交WriteScalar,一個受guard的batch設定typed欄位並標記完成;不能接受UI/外部任意SQL。先前small-row buffer內的scalar與文字/binary分別移交對應方法,無第二次來源讀取。CompleteColumn用於分段欄位,核對非NULL值的chunk/bytes並標記完成;NULL狀態與空值保持區別。 -- [ ] **1. 寫失敗測試,group `lob`。** 單元用惰性 reader 供 64 MiB binary、超過 16 MiB Unicode 與多欄位無 PK;不在 fixture 自己先配一整塊大字串。assert: +- [x] **1. 寫失敗測試,group `lob`。** 單元用惰性 reader 供 64 MiB binary、超過 16 MiB Unicode 與多欄位無 PK;不在 fixture 自己先配一整塊大字串。assert: ```csharp Assert(maxRawBinaryChunk <= 65536, "Bound raw binary chunk"); Assert(maxTextCodeUnits <= 16384, "Bound UTF-16 chunk"); @@ -249,10 +249,10 @@ internal void CommitRow(TextWriter output); Assert(finalTargetInserts == 1, "Only insert the complete row"); Assert(sourceReadPasses == 1, "No PK or row reread required"); ``` -- [ ] **2. 跑 lob group 為紅。** 測 NULL 與空值、尾空白、surrogate boundary、bytes 膨脹、small→large切換、text/ntext/image、untyped XML、cancel 中途。測試生成 guard 結構;SQL engine 實際拒絕缺段由 Task 10 驗證。 -- [ ] **3. 實作同 session 私有 staging protocol。** 使用工作專屬 `#temp` 名稱;每列紀錄 job/row/column/chunk index/received bytes/expected totals/null/completed/failed/consumed。非NULL先空 max 值,`.WRITE(chunk,NULL,0)` 追加;每個 batch 驗證上一狀態,以短 transaction 原子更新值與計數,錯誤 rollback 後標記失敗再拋出。NULL 不用 `.WRITE(NULL,...)` 代替。 -- [ ] **4. 實作最後 guard 與目標插入。** 同一短 transaction 驗證所有欄位完成、DATALENGTH、chunk數、順序及未 consumed,才 INSERT 並 consumed。staging缺失、缺段、重複、錯序、轉型失敗均無目標半列;不要用 TRY_CONVERT 的 NULL 吞錯。TRY/CATCH 每個 batch 完整閉合,變數不跨 GO;清除已插列內容但保留必要的消費狀態以拒絕重送最後批次。一般多列還原不承諾全檔單一 transaction。 -- [ ] **5. 跑 lob/values groups 為綠並提交。** 檢查輸出每個 statement/batch實際UTF-8大小;不以擴高16MiB門檻充當分段。建議訊息 `feat(mssql-export): reconstruct large values with guarded SQL chunks`。 +- [x] **2. 跑 lob group 為紅。** 測 NULL 與空值、尾空白、surrogate boundary、bytes 膨脹、small→large切換、text/ntext/image、untyped XML、cancel 中途。測試生成 guard 結構;SQL engine 實際拒絕缺段由 Task 10 驗證。 +- [x] **3. 實作同 session 私有 staging protocol。** 使用工作專屬 `#temp` 名稱;每列紀錄 job/row/column/chunk index/received bytes/expected totals/null/completed/failed/consumed。非NULL先空 max 值,`.WRITE(chunk,NULL,0)` 追加;每個 batch 驗證上一狀態,以短 transaction 原子更新值與計數,錯誤 rollback 後標記失敗再拋出。NULL 不用 `.WRITE(NULL,...)` 代替。 +- [x] **4. 實作最後 guard 與目標插入。** 同一短 transaction 驗證所有欄位完成、DATALENGTH、chunk數、順序及未 consumed,才 INSERT 並 consumed。staging缺失、缺段、重複、錯序、轉型失敗均無目標半列;不要用 TRY_CONVERT 的 NULL 吞錯。TRY/CATCH 每個 batch 完整閉合,變數不跨 GO;清除已插列內容但保留必要的消費狀態以拒絕重送最後批次。一般多列還原不承諾全檔單一 transaction。 +- [x] **5. 跑 lob/values groups 為綠並提交。** 檢查輸出每個 statement/batch實際UTF-8大小;不以擴高16MiB門檻充當分段。建議訊息 `feat(mssql-export): reconstruct large values with guarded SQL chunks`。 ## Task 6:有界檔案驗證、安全發布與鏡像 From c1f553b6eb143c864809ac2a463696f6763b6030 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 15:27:57 +0800 Subject: [PATCH 15/32] feat(mssql-export): orchestrate cancellable export with measured progress --- .../lib/MssqlExport/MssqlExportModels.cs | 56 ++++++ .../MssqlExportProgressEstimator.cs | 115 ++++++++++++ .../lib/MssqlExport/MssqlSqlExportService.cs | 147 +++++++++++++++ mySQLPunk/mySQLPunk.csproj | 2 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Worker.cs | 171 ++++++++++++++++++ tests/SmokeTests.cs | 4 + 7 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs create mode 100644 mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs create mode 100644 tests/SmokeTests.MssqlExport.Worker.cs diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs index 70a646f..48c78e5 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -83,6 +83,7 @@ public MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList RowCounts { get; } + public TimeSpan Elapsed { get; } + public IReadOnlyList Warnings { get; } + public IReadOnlyList ResidualPaths { get; } + public string ErrorMessage { get; } + internal MssqlExportResult(Guid jobId, MssqlExportStage stage, string publishedPath, string mirrorPath, + IEnumerable rows, TimeSpan elapsed, IEnumerable warnings, IEnumerable residuals, string errorMessage) + { + JobId = jobId; Stage = stage; PublishedPath = publishedPath; MirrorPath = mirrorPath; RowCounts = MssqlNativeFacts.Copy(rows); + Elapsed = elapsed; Warnings = MssqlNativeFacts.Copy(warnings); ResidualPaths = MssqlNativeFacts.Copy(residuals); ErrorMessage = errorMessage; + } + } + } diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs b/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs new file mode 100644 index 0000000..1ad0e01 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace mySQLPunk.lib.MssqlExport +{ + // All time and work are supplied by the caller. This class neither polls nor queries SQL. + internal sealed class MssqlExportProgressEstimator + { + private readonly Guid jobId; + private readonly MssqlExportSchema schema; + private readonly bool mirror; + private readonly HashSet completed = new HashSet(); + private readonly Queue> samples = new Queue>(); + private MssqlObjectKey current; + private long tableRows, tableBytes, completedRows, completedBytes, lastRows; + private MssqlExportStage lastStage; + private bool emitted; + private TimeSpan lastReport, lastAdvance; + private double lastWork; + private double? lastPercent; + internal MssqlExportProgressEstimator(Guid jobId, MssqlExportSchema schema, bool includeMirror) + { this.jobId = jobId; this.schema = schema ?? throw new ArgumentNullException(nameof(schema)); mirror = includeMirror; } + internal MssqlExportProgress Sample(MssqlExportStage stage, MssqlObjectKey currentTable, long currentTableRows, + long currentTableSourceBytes, long writtenBytes, TimeSpan elapsed, bool force) + { return Build(stage, currentTable, currentTableRows, currentTableSourceBytes, writtenBytes, 0, null, elapsed, force); } + internal MssqlExportProgress SamplePhase(MssqlExportStage stage, long processedBytes, long totalBytes, long writtenBytes, TimeSpan elapsed, bool force) + { return Build(stage, null, 0, 0, writtenBytes, processedBytes, totalBytes, elapsed, force || processedBytes == totalBytes); } + private MssqlExportProgress Build(MssqlExportStage stage, MssqlObjectKey table, long rows, long bytes, long written, + long phaseBytes, long? phaseTotal, TimeSpan elapsed, bool force) + { + bool changedTable = !Same(current, table), changedStage = !emitted || lastStage != stage; + bool terminal = stage == MssqlExportStage.Completed || stage == MssqlExportStage.Canceled || stage == MssqlExportStage.Failed || stage == MssqlExportStage.LocalCompletedMirrorIncomplete; + if (changedTable) + { + if (current != null) + { + // A terminal failure does not claim that its interrupted table completed. + completedRows += tableRows; completedBytes += tableBytes; + if (!terminal || stage == MssqlExportStage.Completed) completed.Add(current.ObjectId); + } + current = table; tableRows = 0; tableBytes = 0; + } + tableRows = Math.Max(tableRows, rows); tableBytes = Math.Max(tableBytes, bytes); + long actualRows = completedRows + tableRows, actualBytes = completedBytes + tableBytes; + double work = stage == MssqlExportStage.Data ? actualBytes : phaseBytes; + if (changedStage) { samples.Clear(); lastWork = work; lastAdvance = elapsed; } + else if (work > lastWork || stage == MssqlExportStage.Data && actualRows > lastRows) { lastAdvance = elapsed; lastWork = work; } + lastRows = actualRows; + // Actual progress time is updated even for suppressed UI samples. + if (!force && !changedStage && !changedTable && !terminal && elapsed - lastReport < TimeSpan.FromMilliseconds(200)) return null; + if (changedStage || samples.Count == 0 || elapsed > samples.Last().Item1) samples.Enqueue(Tuple.Create(elapsed, work)); + while (samples.Count > 1 && elapsed - samples.Peek().Item1 > TimeSpan.FromSeconds(10)) samples.Dequeue(); + lastStage = stage; lastReport = elapsed; emitted = true; + long? estimatedRows = schema.Tables.All(t => t.EstimatedRows.HasValue) ? (long?)schema.Tables.Sum(t => t.EstimatedRows.Value) : null; + long? estimatedBytes = schema.Tables.All(t => t.EstimatedBytes.HasValue) ? (long?)schema.Tables.Sum(t => t.EstimatedBytes.Value) : null; + double? remaining = null, candidate = null; + bool unreliable = false; + double dataCeiling = mirror ? 75 : 85; + if (stage == MssqlExportStage.Data) + { + double rest = 0; bool known = true; + foreach (var t in schema.Tables) + { + if (completed.Contains(t.Key.ObjectId)) continue; + if (!t.EstimatedRows.HasValue || !t.EstimatedBytes.HasValue) { known = false; continue; } + if (Same(t.Key, current)) + { + if (tableRows > t.EstimatedRows || tableBytes > t.EstimatedBytes * 4.0) unreliable = true; + double width = tableRows > 0 ? tableBytes / (double)tableRows : t.EstimatedBytes.Value / (double)Math.Max(1, t.EstimatedRows.Value); + rest += Math.Max(0, Math.Max(t.EstimatedBytes.Value - tableBytes, (t.EstimatedRows.Value - tableRows) * width)); + } + else rest += t.EstimatedBytes.Value; + } + if (known && !unreliable) { remaining = rest; candidate = dataCeiling * actualBytes / Math.Max(1, actualBytes + rest); } + } + else if (stage == MssqlExportStage.Verifying || stage == MssqlExportStage.Mirroring) + { + double start = stage == MssqlExportStage.Verifying ? dataCeiling : 90; + double span = stage == MssqlExportStage.Verifying ? 10 : 9; + if (phaseTotal.HasValue) { remaining = Math.Max(0, phaseTotal.Value - phaseBytes); candidate = start + span * Math.Min(1, phaseBytes / (double)Math.Max(1, phaseTotal.Value)); } + else candidate = start; + } + else if (stage == MssqlExportStage.Publishing) candidate = mirror ? 85 : 95; + if (candidate.HasValue) lastPercent = Math.Max(lastPercent ?? 0, Math.Min(99, candidate.Value)); + TimeSpan? eta = null; double? bytesPerSecond = null; var state = unreliable ? MssqlExportEstimationState.Unreliable : MssqlExportEstimationState.Estimating; + if (elapsed - lastAdvance >= TimeSpan.FromSeconds(5)) state = MssqlExportEstimationState.Waiting; + else if (!unreliable && samples.Count >= 3 && elapsed - samples.Peek().Item1 >= TimeSpan.FromSeconds(2)) + { + var points = samples.ToArray(); double low = double.MaxValue, high = 0; + for (int i = 1; i < points.Length; i++) + { + double dt = (points[i].Item1 - points[i - 1].Item1).TotalSeconds; + double rate = dt > 0 ? (points[i].Item2 - points[i - 1].Item2) / dt : 0; + if (rate > 0) { low = Math.Min(low, rate); high = Math.Max(high, rate); } + } + double speed = (work - points[0].Item2) / (elapsed - points[0].Item1).TotalSeconds; + if (speed > 0 && high <= low * 4) + { + bytesPerSecond = speed; + double? seconds = remaining / speed; + if (seconds > 0 && seconds < TimeSpan.MaxValue.TotalSeconds) { eta = TimeSpan.FromSeconds(seconds.Value); state = MssqlExportEstimationState.Measuring; } + } + else state = MssqlExportEstimationState.Unreliable; + } + if (terminal || stage == MssqlExportStage.Publishing) { eta = null; bytesPerSecond = null; } + if (stage == MssqlExportStage.Completed) { lastPercent = 100; state = MssqlExportEstimationState.Complete; } + string message = state == MssqlExportEstimationState.Waiting ? "Waiting for progress" : stage.ToString(); + return new MssqlExportProgress(jobId, stage, current, tableRows, tableBytes, actualRows, actualBytes, written, + estimatedRows, estimatedBytes, completed.Count, schema.Tables.Count, elapsed, lastAdvance, bytesPerSecond, eta, lastPercent, state, phaseBytes, phaseTotal, message); + } + private static bool Same(MssqlObjectKey a, MssqlObjectKey b) + { return ReferenceEquals(a, b) || a != null && b != null && a.ObjectId == b.ObjectId && a.Schema == b.Schema && a.Name == b.Name; } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs new file mode 100644 index 0000000..f58a35f --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; + +namespace mySQLPunk.lib.MssqlExport +{ + internal class MssqlSqlExportService + { + private readonly Func sourceFactory; + private readonly MssqlArtifactFiles files; + private readonly Action localPhase; + internal MssqlSqlExportService(Func sourceFactory) : this(sourceFactory, new MssqlArtifactFiles(), null) { } + internal MssqlSqlExportService(Dictionary connection) : this(OwnedFactory(connection)) { } + // Existing file operations plus a local phase boundary are sufficient for deterministic faults. + internal MssqlSqlExportService(Func sourceFactory, MssqlArtifactFiles files, Action localPhase) + { this.sourceFactory = sourceFactory ?? throw new ArgumentNullException(nameof(sourceFactory)); this.files = files ?? throw new ArgumentNullException(nameof(files)); this.localPhase = localPhase; } + private static Func OwnedFactory(Dictionary connection) + { + if (connection == null) throw new ArgumentNullException(nameof(connection)); + var settings = new Dictionary(connection); + return () => new MssqlExportSource(ConnectionOpenService.OpenMssqlSession(new Dictionary(settings))); + } + internal virtual MssqlExportCatalog LoadCatalog(string databaseName, CancellationToken cancellation) + { + try { cancellation.ThrowIfCancellationRequested(); using (var source = sourceFactory()) return source.ReadCatalog(databaseName, cancellation); } + catch (Exception ex) { throw SafeInspectionFailure("catalog", ex, cancellation); } + } + internal virtual MssqlExportPreflight Preflight(MssqlExportRequest request, CancellationToken cancellation) + { + try { cancellation.ThrowIfCancellationRequested(); using (var source = sourceFactory()) return source.Inspect(request, cancellation); } + catch (Exception ex) { throw SafeInspectionFailure("preflight", ex, cancellation); } + } + private static Exception SafeInspectionFailure(string operation, Exception error, CancellationToken cancellation) + { + if (cancellation.IsCancellationRequested) return new OperationCanceledException(cancellation); + // No original exception as InnerException: even ToString must remain safe for a UI log. + return new InvalidOperationException("Export " + operation + " failed (" + error.GetType().Name + ")."); + } + internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress progress, CancellationToken cancellation) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + var clock = Stopwatch.StartNew(); var counts = new List(); + var warnings = new List(); var residuals = new List(); + MssqlExportArtifact artifact = null; MssqlExportMirror mirror = null; + var estimator = new MssqlExportProgressEstimator(request.JobId, new MssqlExportSchema(null, null, null, 0, null), request.Mirror != null); + var stage = MssqlExportStage.Preflight; bool succeeded = false; bool canceled = false; string error = null; + long written = 0; + Action report = p => { if (p != null && progress != null) { try { progress.Report(p); } catch { /* Observer failure must not change publication truth. */ } } }; + Action phase = s => { + stage = s; report(estimator.Sample(s, null, 0, 0, written, clock.Elapsed, true)); + localPhase?.Invoke(s); cancellation.ThrowIfCancellationRequested(); + }; + try + { + report(estimator.Sample(stage, null, 0, 0, 0, clock.Elapsed, true)); + cancellation.ThrowIfCancellationRequested(); + using (var source = sourceFactory()) + { + var preflight = source.Inspect(request, cancellation); + if (!preflight.CanStart) throw new InvalidOperationException("Export preflight did not pass."); + var schema = source.BeginRead(request, cancellation); + estimator = new MssqlExportProgressEstimator(request.JobId, schema, request.Mirror != null); + cancellation.ThrowIfCancellationRequested(); + artifact = MssqlExportArtifact.Create(request, files); + // Wrapper releases before Complete, underlying artifact remains owned by this job. + using (var writer = new StreamWriter(artifact.Output, new UTF8Encoding(false, true), 8192, true)) + { + var ddl = new MssqlSchemaScriptWriter(); + var data = new MssqlDataScriptWriter(new MssqlLobScriptWriter(request.JobId)); + stage = MssqlExportStage.Schema; + report(estimator.Sample(stage, null, 0, 0, artifact.Output.Position, clock.Elapsed, true)); + ddl.WriteBeforeData(schema, request, writer, cancellation); + foreach (var table in schema.Tables) + { + long rows = 0, sourceBytes = 0; + stage = request.Mode == MssqlExportMode.SchemaOnly ? MssqlExportStage.Schema : MssqlExportStage.Data; + report(estimator.Sample(stage, table.Key, 0, 0, artifact.Output.Position, clock.Elapsed, true)); + if (request.Mode != MssqlExportMode.SchemaOnly) + { + using (var reader = source.OpenRows(table, cancellation)) + { + rows = data.WriteTable(reader.Reader, table, writer, (r, b) => { + rows = r; sourceBytes = b; written = artifact.Output.Position; + report(estimator.Sample(stage, table.Key, r, b, written, clock.Elapsed, false)); + }, cancellation); + } + } + counts.Add(new MssqlTableRowCount(table.Key, rows)); + written = artifact.Output.Position; + report(estimator.Sample(stage, table.Key, rows, sourceBytes, written, clock.Elapsed, true)); + } + stage = MssqlExportStage.Schema; + report(estimator.Sample(stage, null, 0, 0, artifact.Output.Position, clock.Elapsed, true)); + ddl.WriteAfterData(schema, request, writer, cancellation); + writer.Flush(); written = artifact.Output.Position; + } + source.CompleteRead(schema, cancellation); + } + // No snapshot/reader/command/connection is alive during these local phases. + var receipt = artifact.Complete(counts.AsReadOnly(), cancellation); written = receipt.Bytes; + phase(MssqlExportStage.Verifying); + artifact.Verify(receipt, (done, total) => report(estimator.SamplePhase(stage, done, total, written, clock.Elapsed, false)), cancellation); + phase(MssqlExportStage.Publishing); // Includes cancelable integrity/consent preparation. + artifact.Publish(request.Destination, cancellation); // Only the artifact's actual commit defers cancellation. + if (request.Mirror != null) + { + mirror = new MssqlExportMirror(files); + phase(MssqlExportStage.Mirroring); + long last = 0; bool verifyPass = false; + mirror.CopyVerified(artifact.PublishedPath, receipt, request.Mirror, (done, total) => { + // CopyVerified reports a copy pass followed by a verification pass (which restarts at zero). + if (!verifyPass && last == total && done <= last) verifyPass = true; + last = done; + report(estimator.SamplePhase(stage, (verifyPass ? total : 0) + done, checked(total * 2), written, clock.Elapsed, false)); + }, cancellation); + } + succeeded = true; + } + catch (Exception ex) + { + canceled = cancellation.IsCancellationRequested; + // SQL/provider messages can contain credentials or row values. Return only a safe category. + error = canceled ? "Export canceled." : "Export failed during " + stage + " (" + ex.GetType().Name + ")."; + } + finally + { + if (artifact != null) + { + written = artifact.Output.Position; + artifact.Dispose(); + warnings.AddRange(artifact.Warnings); residuals.AddRange(artifact.ResidualPaths); + } + // Mirror owns its temporary lifetime inside CopyVerified's finally. + if (mirror != null) { warnings.AddRange(mirror.Warnings); residuals.AddRange(mirror.ResidualPaths); } + } + string published = artifact?.PublishedPath, mirrorPath = mirror?.MirrorPublishedPath; + if (succeeded) stage = MssqlExportStage.Completed; + else if (published != null) stage = MssqlExportStage.LocalCompletedMirrorIncomplete; + else stage = canceled ? MssqlExportStage.Canceled : MssqlExportStage.Failed; + report(estimator.Sample(stage, null, 0, 0, written, clock.Elapsed, true)); + return new MssqlExportResult(request.JobId, stage, published, mirrorPath, counts, clock.Elapsed, warnings, residuals, error); + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 24aee0c..46a9a6b 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -360,6 +360,8 @@ + + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index 6dd9b8e..ba9de73 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Worker.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Worker.cs b/tests/SmokeTests.MssqlExport.Worker.cs new file mode 100644 index 0000000..228c6ab --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Worker.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Threading; +using mySQLPunk.lib.MssqlExport; +public static partial class SmokeTests +{ + private static void Worker_RequiresService() + { + Assert(typeof(MssqlObjectKey).Assembly.GetType("mySQLPunk.lib.MssqlExport.MssqlSqlExportService") != null, "Worker must orchestrate owned source and verified publication"); + } + private static MssqlExportSchema WorkerSchema(bool estimates = true) + { + return new MssqlExportSchema(new[] { 1, 2 }.Select(i => new MssqlTableDefinition(new MssqlObjectKey(i, "dbo", "t" + i), + estimates ? new Dictionary { { "estimated_rows", 100L }, { "estimated_bytes", i == 1 ? 1000L : 100000L } } : null, + new[] { new MssqlColumnDefinition(new Dictionary { { "column_id", 1 }, { "name", "id" }, { "type_name", "int" }, { "type_schema", "sys" } }) }, null, null)), null, null, 1, null); + } + private static void Worker_Progress() + { + var schema = WorkerSchema(); var id = Guid.NewGuid(); var e = new MssqlExportProgressEstimator(id, schema, true); + var initial = e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 0, 0, 0, TimeSpan.Zero, false); + Assert(initial.Eta == null, "No invented initial ETA"); + Assert(e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 1, 10, 10, TimeSpan.FromMilliseconds(50), false) == null, "200ms throttle"); + e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 10, 100, 100, TimeSpan.FromSeconds(1), false); + var ready = e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 20, 200, 200, TimeSpan.FromSeconds(2), false); + Assert(ready.BytesPerSecond == 100 && ready.LastProgressElapsed == TimeSpan.FromSeconds(2) && ready.Eta.HasValue && ready.Eta > TimeSpan.Zero, "Three samples and two seconds enable measured ETA"); + var stalled = e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 20, 200, 200, TimeSpan.FromSeconds(7), false); + Assert(stalled.BytesPerSecond == null && stalled.LastProgressElapsed == TimeSpan.FromSeconds(2) && stalled.Eta == null && stalled.EstimationState == MssqlExportEstimationState.Waiting, "Stall clears countdown"); + var over = e.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 150, 1500, 3000, TimeSpan.FromSeconds(8), true); + Assert(over.Eta == null && over.Percent <= 99, "Underestimate cannot promise completion"); + var next = e.Sample(MssqlExportStage.Data, schema.Tables[1].Key, 1, 1000, 4000, TimeSpan.FromSeconds(9), false); + Assert(next.CompletedTables == 1 && next.Rows == 151 && next.SourceBytes == 2500, "Table switch accumulates actual values"); + Assert(next.Percent >= ready.Percent && next.Percent < 50, "Wide table dominates work and percent is monotonic"); + var phase = e.SamplePhase(MssqlExportStage.Verifying, 500, 1000, 4000, TimeSpan.FromSeconds(10), false); + Assert(phase.WrittenBytes == 4000 && phase.PhaseProcessedBytes == 500 && phase.PhaseTotalBytes == 1000, "Local read bytes are not SQL write bytes"); + var final = e.Sample(MssqlExportStage.Completed, null, 0, 0, 4000, TimeSpan.FromSeconds(10), false); + Assert(final.Percent == 100 && final.JobId == id, "Terminal bypasses throttle and carries job identity"); + var emptyValues = new MssqlExportProgressEstimator(id, schema, false); + emptyValues.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 1, 0, 50, TimeSpan.Zero, true); + var moreEmpty = emptyValues.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 2, 0, 100, TimeSpan.FromSeconds(6), true); + Assert(moreEmpty.EstimationState != MssqlExportEstimationState.Waiting, "Rows with no source payload still make real progress"); + var unknown = new MssqlExportProgressEstimator(Guid.NewGuid(), WorkerSchema(false), false); + Assert(unknown.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 20, 200, 200, TimeSpan.FromSeconds(3), true).Eta == null, "Unknown metadata remains unknown"); + var fast = new MssqlExportProgressEstimator(id, schema, false); + fast.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 0, 0, 0, TimeSpan.Zero, true); + fast.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 1, 10, 10, TimeSpan.FromSeconds(1), true); + Assert(fast.Sample(MssqlExportStage.Data, schema.Tables[0].Key, 2, 900, 900, TimeSpan.FromSeconds(2), true).Eta == null, "Abrupt rate change is unknown"); + } + private static void Worker_Lifecycle() + { + string root = Path.Combine(Path.GetTempPath(), "mssql-worker-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + try + { + foreach (var mode in new[] { MssqlExportMode.DataOnly, MssqlExportMode.SchemaOnly, MssqlExportMode.SchemaAndData }) + { + var calls = new List(); var schema = WorkerSchema(); var source = new WorkerSource(schema, calls); + var service = new MssqlSqlExportService(() => source, new MssqlArtifactFiles(), s => calls.Add("artifact." + s.ToString().ToLowerInvariant())); + var target = Path.Combine(root, mode + ".sql"); var request = WorkerRequest(target, schema, mode, null); + var samples = new List(); var result = service.Export(request, new WorkerProgress(samples.Add), CancellationToken.None); + Assert(result.Stage == MssqlExportStage.Completed && result.PublishedPath == target, "Export publishes complete result"); + Assert(calls.IndexOf("source.dispose") < calls.IndexOf("artifact.verifying"), "Release snapshot before local work"); + Assert(calls.IndexOf("source.inspect") < calls.IndexOf("source.begin"), "Worker rechecks preflight"); + Assert(calls.LastIndexOf("command.dispose") < calls.IndexOf("source.complete"), "Close command before completing snapshot"); + Assert(calls.Count(x => x == "source.rows") == (mode == MssqlExportMode.SchemaOnly ? 0 : 2), "SchemaOnly never opens data"); + Assert(result.RowCounts.All(x => x.Rows == (mode == MssqlExportMode.SchemaOnly ? 0 : 3)), "Fresh reader includes first row and all rows"); + var sql = File.ReadAllText(target); + Assert(sql.Contains("CREATE TABLE") == (mode != MssqlExportMode.DataOnly), "DataOnly omits DDL"); + Assert(samples.Where(x => x.Stage != MssqlExportStage.Completed).All(x => x.Percent == null || x.Percent <= 99), "No early success"); + Assert(samples.All(x => x.JobId == request.JobId), "Reports keep operation identity"); + Assert(samples.Last().Rows == result.RowCounts.Sum(x => x.Rows), "Final progress preserves actual totals"); + } + var mirrorSchema = WorkerSchema(); var mirrorSamples = new List(); + var mirrorRequest = WorkerRequest(Path.Combine(root, "mirror_backup_ok.sql"), mirrorSchema, MssqlExportMode.DataOnly, new MssqlMirrorOptions(Path.Combine(root, "mirror"), 2)); + var mirrorResult = new MssqlSqlExportService(() => new WorkerSource(mirrorSchema, new List())).Export(mirrorRequest, new WorkerProgress(mirrorSamples.Add), CancellationToken.None); + Assert(mirrorResult.Stage == MssqlExportStage.Completed && File.Exists(mirrorResult.MirrorPath), "Verified mirror success"); + var mirrorDone = mirrorSamples.Last(x => x.Stage == MssqlExportStage.Mirroring); + Assert(mirrorDone.PhaseProcessedBytes == 2 * mirrorDone.WrittenBytes && mirrorDone.PhaseTotalBytes == 2 * mirrorDone.WrittenBytes, "Mirror accounts for copy and verify without double-counting SQL writes"); + for (int i = 1; i < mirrorSamples.Count; i++) Assert(!mirrorSamples[i].Percent.HasValue || !mirrorSamples[i-1].Percent.HasValue || mirrorSamples[i].Percent >= mirrorSamples[i-1].Percent, "Overall progress never resets"); + int disposed = 0; var catalogService = new MssqlSqlExportService(() => new WorkerSource(WorkerSchema(), new List(), () => disposed++)); + catalogService.LoadCatalog("fixture", CancellationToken.None); + catalogService.Preflight(WorkerRequest(Path.Combine(root, "preflight.sql"), WorkerSchema(), MssqlExportMode.DataOnly, null), CancellationToken.None); + Assert(disposed == 2, "Catalog and preflight own separate short sources"); + var failingCatalog = new MssqlSqlExportService(() => { throw new IOException("private connection-string payload"); }); + try { failingCatalog.LoadCatalog("fixture", CancellationToken.None); Assert(false, "Catalog failure required"); } + catch (Exception ex) { Assert(!ex.ToString().Contains("private connection-string"), "Catalog does not leak provider exception details"); } + try { failingCatalog.Preflight(mirrorRequest, CancellationToken.None); Assert(false, "Preflight failure required"); } + catch (Exception ex) { Assert(!ex.ToString().Contains("private connection-string"), "Preflight does not leak provider exception details"); } + + } + finally { Directory.Delete(root, true); } + } + private static void Worker_Failures() + { + string root = Path.Combine(Path.GetTempPath(), "mssql-worker-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + try + { + foreach (string fault in new[] { "inspect", "begin", "rows", "complete", "write", "verify", "mirror", "cancel-read", "cancel-write", "cancel-verify", "cancel-verify-read", "cancel-publish", "cancel-commit", "cancel-mirror", "cancel-mirror-read", "cleanup" }) + using (var cancel = new CancellationTokenSource()) + { + var schema = WorkerSchema(); var calls = new List(); var source = new WorkerSource(schema, calls); + source.Fault = fault; source.Cancel = cancel; + string target = Path.Combine(root, fault + "_backup_out.sql"); File.WriteAllText(target, "original"); + var mirror = fault.Contains("mirror") ? new MssqlMirrorOptions(Path.Combine(root, "mirror-" + fault), 2) : null; + var request = WorkerRequest(target, schema, MssqlExportMode.DataOnly, mirror); + var files = new WorkerFiles(fault, cancel); var samples = new List(); + var service = new MssqlSqlExportService(() => source, files, stage => { + calls.Add("artifact." + stage.ToString().ToLowerInvariant()); + if ((fault == "verify" || fault == "cleanup") && stage == MssqlExportStage.Verifying) throw new IOException("private connection-string payload"); + if (fault == "cancel-verify" && stage == MssqlExportStage.Verifying || fault == "cancel-publish" && stage == MssqlExportStage.Publishing || fault == "cancel-mirror" && stage == MssqlExportStage.Mirroring) cancel.Cancel(); + }); + var result = service.Export(request, new WorkerProgress(p => { samples.Add(p); if (fault == "cancel-verify-read" && p.Stage == MssqlExportStage.Verifying && p.PhaseProcessedBytes > 0 || fault == "cancel-mirror-read" && p.Stage == MssqlExportStage.Mirroring && p.PhaseProcessedBytes == p.PhaseTotalBytes) cancel.Cancel(); }), cancel.Token); + Assert(calls.Contains("source.dispose"), "Dispose source after " + fault); + if (mirror != null) Assert(result.PublishedPath == target && result.Stage == MssqlExportStage.LocalCompletedMirrorIncomplete, "Keep local outcome after " + fault); + else if (fault == "cancel-commit") Assert(result.PublishedPath == target && result.Stage == MssqlExportStage.Completed, "Late commit cancellation keeps committed success"); + else { + Assert(result.PublishedPath == null && File.ReadAllText(target) == "original", "Failure preserves approved old output: " + fault); + Assert(result.Stage == (fault.StartsWith("cancel") ? MssqlExportStage.Canceled : MssqlExportStage.Failed), "Correct terminal: " + fault); + } + Assert(!string.Join(" ", result.Warnings).Contains("private connection-string"), "Do not expose source exception payloads"); + if (fault == "inspect" || fault == "begin") Assert(files.Creates == 0, "Preflight failure creates no partial"); + if (fault == "cleanup") Assert(result.ResidualPaths.Any() && result.Warnings.Any(), "Collect cleanup evidence after Dispose"); + else Assert(!Directory.GetFiles(root, "*.partial", SearchOption.AllDirectories).Any(x => x.Contains(request.JobId.ToString("N"))), "Owned partial cleaned"); + Assert(samples.Last().Stage == result.Stage, "Terminal after cleanup"); + } + } + finally { Directory.Delete(root, true); } + } + private static MssqlExportRequest WorkerRequest(string target, MssqlExportSchema schema, MssqlExportMode mode, MssqlMirrorOptions mirror) + { return new MssqlExportRequest(Guid.NewGuid(), "fixture", schema.Tables.Select(x => x.Key).ToArray(), mode, false, target, MssqlExportArtifact.CaptureDestination(target, CancellationToken.None), mirror); } + private sealed class WorkerProgress : IProgress + { private readonly Action action; internal WorkerProgress(Action action) { this.action = action; } public void Report(MssqlExportProgress p) { action(p); } } + private sealed class WorkerDispose : IDisposable + { private readonly Action action; internal WorkerDispose(Action action) { this.action = action; } public void Dispose() { action(); } } + private sealed class WorkerSource : IMssqlExportSource + { + private readonly MssqlExportSchema schema; private readonly List calls; private readonly Action disposed; private bool closed; + internal string Fault; internal CancellationTokenSource Cancel; + internal WorkerSource(MssqlExportSchema schema, List calls, Action disposed = null) { this.schema = schema; this.calls = calls; this.disposed = disposed; calls.Add("source.open"); } + public MssqlExportCatalog ReadCatalog(string db, CancellationToken c) { c.ThrowIfCancellationRequested(); return new MssqlExportCatalog(schema.Tables, 1, null); } + public MssqlExportPreflight Inspect(MssqlExportRequest r, CancellationToken c) { calls.Add("source.inspect"); return new MssqlExportPreflight(schema, Fault == "inspect" ? new[] { new MssqlExportIssue("Denied", null, null, "denied") } : null); } + public MssqlExportSchema BeginRead(MssqlExportRequest r, CancellationToken c) { calls.Add("source.begin"); if (Fault == "begin") throw new IOException("snapshot failed"); return schema; } + public MssqlRowReader OpenRows(MssqlTableDefinition t, CancellationToken c) { + calls.Add("source.rows"); if (Fault == "rows") throw new IOException("DB disconnected private connection-string"); + if (Fault == "cancel-read") { Cancel.Cancel(); c.ThrowIfCancellationRequested(); } + var data = new DataTable(); data.Columns.Add("id", typeof(int)); data.Rows.Add(1); data.Rows.Add(2); data.Rows.Add(3); + var reader = data.CreateDataReader(); return new MssqlRowReader(reader, new WorkerDispose(() => { Assert(reader.IsClosed, "Reader closes before command"); calls.Add("command.dispose"); })); + } + public void CompleteRead(MssqlExportSchema s, CancellationToken c) { calls.Add("source.complete"); if (Fault == "complete") throw new InvalidOperationException("schema fingerprint changed"); } + public void Dispose() { if (closed) return; closed = true; calls.Add("source.dispose"); disposed?.Invoke(); } + } + private sealed class WorkerFiles : MssqlArtifactFiles + { + private readonly string fault; private readonly CancellationTokenSource cancel; internal int Creates; + internal WorkerFiles(string fault, CancellationTokenSource cancel) { this.fault = fault; this.cancel = cancel; } + internal override Stream Create(string path) { Creates++; if (fault == "mirror" && Creates == 2) throw new IOException("mirror unavailable"); return new WorkerStream(base.Create(path), fault, cancel); } + internal override void Replace(string source, string target, string recovery) { if (fault == "cancel-commit") cancel.Cancel(); base.Replace(source, target, recovery); } + internal override void Delete(string path) { if (fault == "cleanup") throw new IOException("cleanup failed"); base.Delete(path); } + } + private sealed class WorkerStream : Stream + { + private readonly Stream inner; private readonly string fault; private readonly CancellationTokenSource cancel; + internal WorkerStream(Stream inner, string fault, CancellationTokenSource cancel) { this.inner = inner; this.fault = fault; this.cancel = cancel; } + public override void Write(byte[] b, int o, int n) { if (fault == "write") throw new IOException("disk full"); if (fault == "cancel-write") { cancel.Cancel(); cancel.Token.ThrowIfCancellationRequested(); } inner.Write(b,o,n); } + public override void Flush() { inner.Flush(); } protected override void Dispose(bool d) { if(d) inner.Dispose(); base.Dispose(d); } + public override bool CanRead => false; public override bool CanSeek => false; public override bool CanWrite => true; + public override long Length => inner.Length; public override long Position { get => inner.Position; set => throw new NotSupportedException(); } + public override int Read(byte[] b,int o,int n) { throw new NotSupportedException(); } public override long Seek(long o,SeekOrigin s) { throw new NotSupportedException(); } public override void SetLength(long n) { throw new NotSupportedException(); } + } +} diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index ff04099..226d4ab 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -28,6 +28,10 @@ public static partial class SmokeTests public static int Main() { int passed = 0; + Run("MSSQL export worker progress", Worker_Progress, ref passed); + Run("MSSQL export worker lifecycle", Worker_Lifecycle, ref passed); + Run("MSSQL export worker failures", Worker_Failures, ref passed); + Run("MSSQL export worker service", Worker_RequiresService, ref passed); Run("MSSQL export schema view headers", Schema_NormalizesOnlyViewHeader, ref passed); Run("MSSQL export schema index option gate", Schema_RejectsUndocumentedIndexOption, ref passed); Run("MSSQL export schema legacy ANSI gates", Schema_RejectsLegacyAnsiSemantics, ref passed); From 400b497ab228048657186f9c895a946fc4b6df02 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 15:31:31 +0800 Subject: [PATCH 16/32] docs(mssql-export): record reviewed export orchestration --- docs/design/mssql-export/implementation-plan.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/design/mssql-export/implementation-plan.md b/docs/design/mssql-export/implementation-plan.md index 5b376a0..118e139 100644 --- a/docs/design/mssql-export/implementation-plan.md +++ b/docs/design/mssql-export/implementation-plan.md @@ -309,17 +309,17 @@ internal MssqlExportProgress Sample(MssqlExportStage stage, MssqlObjectKey curre `currentTableRows/currentTableSourceBytes`為目前表累計,`writtenBytes`為全檔累計;Sample未達節流時可回null,force必須回snapshot。estimator從schema取得nullable估計,切表時記錄已完成實際數;不可自行查DB。LoadCatalog/Preflight都各開短生命週期source並dispose。 -- [ ] **1. 寫失敗測試,group `worker`。** fake source記錄open/begin/read/complete/dispose與檔案步驟順序;估計用fake clock,不sleep。assert: +- [x] **1. 寫失敗測試,group `worker`。** fake source記錄open/begin/read/complete/dispose與檔案步驟順序;估計用fake clock,不sleep。assert: ```csharp Assert(calls.IndexOf("source.dispose") < calls.IndexOf("artifact.verify"), "Release snapshot before local work"); Assert(samples.Where(x => x.Stage != MssqlExportStage.Completed).All(x => x.Percent == null || x.Percent <= 99), "No early success"); Assert(initial.Eta == null && stalled.Eta == null, "No fabricated countdown"); Assert(result.PublishedPath != null && result.Stage == MssqlExportStage.LocalCompletedMirrorIncomplete, "Keep local outcome"); ``` -- [ ] **2. 跑 worker group 為紅。** 讀取/寫入/verify/mirror取消,發布區取消,舊operation回報、DB斷線、schema指紋改變、實際rows超estimate、不同列寬、缺失估計都覆蓋。 -- [ ] **3. 實作階段順序。** 新source重做Inspect→BeginRead→DDL前段→逐表data→DDL後段/schema核對→CompleteRead與source Dispose→artifact Complete/Verify/Publish→選用mirror→結果。預檢失敗不建立partial;UI先前Preflight不跳過開始時的重查。SchemaOnly不讀rows;DataOnly不寫DDL。失敗停止整個工作,不跳表或重連續跑;finally reader/command/transaction/owner及本工作temp順序清理。 -- [ ] **4. 實作進度。** 預設200ms節流,階段/表/terminal強制回報;使用最近10秒樣本,至少3個樣本且至少2秒才估ETA。連續5秒無實際進展時ETA回null並顯示等待。估計remaining work按表估計bytes及實測row-width調整,不按表數等權;估計失準或劇烈速率變化可退回未知。局部verify/mirror以實際bytes計算,整體百分比單調且成功前≤99,不重置0;terminal狀態立即回報。UI log採上限200條。 -- [ ] **5. 跑worker group為綠並提交。** constructor default factory只建立自己的session;不讀全域backup設定、不觸碰共享provider。建議訊息 `feat(mssql-export): orchestrate cancellable export with measured progress`。 +- [x] **2. 跑 worker group 為紅。** 讀取/寫入/verify/mirror取消,發布區取消,舊operation回報、DB斷線、schema指紋改變、實際rows超estimate、不同列寬、缺失估計都覆蓋。 +- [x] **3. 實作階段順序。** 新source重做Inspect→BeginRead→DDL前段→逐表data→DDL後段/schema核對→CompleteRead與source Dispose→artifact Complete/Verify/Publish→選用mirror→結果。預檢失敗不建立partial;UI先前Preflight不跳過開始時的重查。SchemaOnly不讀rows;DataOnly不寫DDL。失敗停止整個工作,不跳表或重連續跑;finally reader/command/transaction/owner及本工作temp順序清理。 +- [x] **4. 實作進度。** 預設200ms節流,階段/表/terminal強制回報;使用最近10秒樣本,至少3個樣本且至少2秒才估ETA。連續5秒無實際進展時ETA回null並顯示等待。估計remaining work按表估計bytes及實測row-width調整,不按表數等權;估計失準或劇烈速率變化可退回未知。局部verify/mirror以實際bytes計算,整體百分比單調且成功前≤99,不重置0;terminal狀態立即回報。UI log採上限200條。 +- [x] **5. 跑worker group為綠並提交。** constructor default factory只建立自己的session;不讀全域backup設定、不觸碰共享provider。建議訊息 `feat(mssql-export): orchestrate cancellable export with measured progress`。 ## Task 8:正式精靈與 Punky From 14ba2fc1384b1c0fdbb817901c95ef06a039f475 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 15:45:47 +0800 Subject: [PATCH 17/32] fix(mssql-export): stage small legacy text rows with native collation --- mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs | 5 ++++- tests/SmokeTests.MssqlExport.Values.cs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs index 309594d..16c4412 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlDataScriptWriter.cs @@ -99,6 +99,9 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr if (capacity < 1024) throw new NotSupportedException("Column metadata exceeds row buffer budget."); var buffer = new char[capacity]; var chunk = new byte[4096]; var starts = new int[columns.Count]; var lengths = new int[columns.Count]; + // Legacy text rejects direct varbinary assignment even for tiny values. + // Typed staging preserves its source codepage without a default-collation cast. + bool requiresTypedStage = columns.Any(c => c.NativeType == "text"); long rows = 0, sourceBytes = 0; bool identity = columns.Any(c => c.Identity), identityOn = false; cancellation.ThrowIfCancellationRequested(); while (reader.Read()) @@ -106,7 +109,7 @@ internal long WriteTable(DbDataReader reader, MssqlTableDefinition table, TextWr cancellation.ThrowIfCancellationRequested(); if (identity && !identityOn) { batches.WriteStatement("SET IDENTITY_INSERT " + target + " ON;"); identityOn = true; } int used = 0; bool handedOff = false; - if (columns.Count > 0 && batches.Bytes(header) + 4 > MssqlExportLimits.StatementBytes) + if (columns.Count > 0 && (requiresTypedStage || batches.Bytes(header) + 4 > MssqlExportLimits.StatementBytes)) { sourceBytes += Handoff(reader, table, columns, new MssqlRowContinuation(buffer, starts, lengths, 0, 0, false, false), batches, cancellation, rows, sourceBytes, onRowsAndSourceBytes); handedOff = true; diff --git a/tests/SmokeTests.MssqlExport.Values.cs b/tests/SmokeTests.MssqlExport.Values.cs index bb3271e..275bb07 100644 --- a/tests/SmokeTests.MssqlExport.Values.cs +++ b/tests/SmokeTests.MssqlExport.Values.cs @@ -36,6 +36,7 @@ private static void Values_Scalars() } private static void Values_ProjectionAndStreaming() { + Values_TinyLegacyText(); AssertEquals("CONVERT(varbinary(max),CONVERT(varchar(max),[v]))", MssqlValueCodec.SourceExpression(ValueColumn("text")), "Legacy ANSI bytes"); AssertEquals("CONVERT(varbinary(max),CONVERT(nvarchar(max),[v]))", MssqlValueCodec.SourceExpression(ValueColumn("xml")), "XML serialized UTF16"); var data = new byte[] { 0x47,0,0x4f,0,0x0a,0,0x3a,0,0x72,0,0,0,0x24,0,0x28,0,0x78,0,0x29,0,0,0xd8 }; @@ -58,6 +59,18 @@ private static void Values_ProjectionAndStreaming() Assert(output.ToString().Contains("VALUES (NULL,CONVERT(nvarchar(max),0x));") && output.ToString().Contains("VALUES (0x,NULL);"),"Empty differs from NULL"); } } + private static void Values_TinyLegacyText() + { + var text = new MssqlColumnDefinition(new Dictionary { {"type_name","text"}, {"column_id",1}, {"name","v"}, {"collation_name","Chinese_Taiwan_Stroke_CI_AS"}, {"max_length",-1} }); + using(var reader = new ValueReader(new[] { new object[] { new byte[] { 0xA4,0xA4,0,32,32 } }, new object[] { new byte[0] }, new object[] { null } })) + using(var output = new StringWriter()) { + long bytes=0; + Assert(new MssqlDataScriptWriter().WriteTable(reader,ValueTable(text),output,(r,b)=>bytes=b,CancellationToken.None)==3,"Tiny legacy text rows consumed once"); + string sql=output.ToString(); + Assert(sql.Contains("varchar(max) COLLATE Chinese_Taiwan_Stroke_CI_AS") && sql.Contains(".WRITE(0xA4A4002020,"),"Tiny text uses typed raw-byte staging instead of illegal varbinary assignment"); + Assert(bytes==5,"Legacy text exact bytes, empty and NULL accounting"); + } + } private static void Values_BudgetsAndHandoff() { var rows=Enumerable.Range(0,80).Select(i=>new object[]{new byte[40000]}).ToArray(); From 263497553f9f68da466a9a7f313cdec6561beead Mon Sep 17 00:00:00 2001 From: John Date: Mon, 12 Oct 2026 16:20:55 +0800 Subject: [PATCH 18/32] feat(mssql-export): add native export wizard and Punky progress --- mySQLPunk/MssqlExportWizardForm.cs | 901 ++++++++++++++++++ .../lib/MssqlExport/MssqlExportModels.cs | 20 + mySQLPunk/mySQLPunk.csproj | 1 + tests/Run-SmokeTests.ps1 | 2 +- tests/SmokeTests.MssqlExport.Wizard.cs | 364 +++++++ tests/SmokeTests.cs | 9 + 6 files changed, 1296 insertions(+), 1 deletion(-) create mode 100644 mySQLPunk/MssqlExportWizardForm.cs create mode 100644 tests/SmokeTests.MssqlExport.Wizard.cs diff --git a/mySQLPunk/MssqlExportWizardForm.cs b/mySQLPunk/MssqlExportWizardForm.cs new file mode 100644 index 0000000..9bdeff4 --- /dev/null +++ b/mySQLPunk/MssqlExportWizardForm.cs @@ -0,0 +1,901 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using mySQLPunk.lib; +using mySQLPunk.lib.MssqlExport; + +namespace mySQLPunk +{ + internal sealed class MssqlExportWizardForm : Form + { + private readonly string database; + private readonly MssqlExportWizardOptions options; + private readonly MssqlSqlExportService service; + private readonly HashSet selected = new HashSet(); + private readonly HashSet processed = new HashSet(); + private readonly Panel[] pages = new Panel[4]; + private readonly TextBox search = new TextBox(), output = new TextBox(); + private readonly DataGridView tables = new DataGridView(), queue = new DataGridView(); + private readonly CheckBox views = new CheckBox(); + private readonly RadioButton schemaData = new RadioButton(), schemaOnly = new RadioButton(), dataOnly = new RadioButton(); + private readonly Label steps = new Label(), status = new Label(), selection = new Label(), confirmation = new Label(); + private readonly Label stage = new Label(), current = new Label(), percent = new Label(), written = new Label(), tableRows = new Label(), elapsed = new Label(), speed = new Label(), eta = new Label(), resultText = new Label(); + private readonly Button next = new Button(), back = new Button(), cancel = new Button(), openFile = new Button(), openFolder = new Button(), sound = new Button(); + private readonly ProgressBar overall = new ProgressBar(), tableProgress = new ProgressBar(); + private readonly PunkyReportLoadingControl punky = new PunkyReportLoadingControl(); + private readonly ListBox log = new ListBox(); + private readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer + { + Interval = 500 + }; + private readonly Stopwatch clock = new Stopwatch(); + private IReadOnlyList catalog = new List(); + private CancellationTokenSource cancellation; + private MssqlExportRequest prepared; + private MssqlExportProgress last; + private bool busy, closing, rebuilding, loaded, exporting, cancelRequested; + private int step, generation; + internal MssqlExportResult Result { get; private set; } + + private static string T(string zh, string en) + { + return ReportText.Get(zh, en); + } + + internal MssqlExportWizardForm(Dictionary connection, string databaseName, MssqlExportWizardOptions options) : this(connection, databaseName, options, null) + { + } + + internal MssqlExportWizardForm(Dictionary connection, string databaseName, MssqlExportWizardOptions options, MssqlSqlExportService service) + { + if (connection == null) + throw new ArgumentNullException(nameof(connection)); + if (string.IsNullOrWhiteSpace(databaseName)) + throw new ArgumentException("Database is required", nameof(databaseName)); + this.database = databaseName; + this.options = options ?? throw new ArgumentNullException(nameof(options)); + var captured = new Dictionary(); + foreach (var pair in connection) + if (!new[] + { + "pdo", + "isConnect", + "connString" + }.Contains(pair.Key, StringComparer.OrdinalIgnoreCase) && (pair.Value == null || pair.Value is string || pair.Value.GetType().IsPrimitive || pair.Value is decimal)) + captured[pair.Key] = pair.Value; + captured["initial_database"] = databaseName; + this.service = service ?? new MssqlSqlExportService(captured); + Name = "mssqlExportWizard"; + Text = T("匯出資料庫 · SQL Server", "Export database · SQL Server"); + AutoScaleMode = AutoScaleMode.Dpi; + Font = UiKit.Body; + ClientSize = new Size(1100, 730); + MinimumSize = new Size(640, 480); + StartPosition = FormStartPosition.CenterParent; + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(24), + ColumnCount = 1, + RowCount = 4 + }; + root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + Controls.Add(root); + var header = Stack(); + header.Controls.Add(Label(T("資料庫匯出精靈", "Database export wizard") + " · " + database + " / MSSQL", UiKit.Title)); + steps.AutoSize = true; + steps.Margin = new Padding(0, 16, 0, 20); + header.Controls.Add(steps); + root.Controls.Add(header, 0, 0); + var body = new Panel + { + Dock = DockStyle.Fill + }; + root.Controls.Add(body, 0, 1); + for (int i = 0; i < 4; i++) + { + pages[i] = new Panel + { + Name = "mssqlStep" + i, + Dock = DockStyle.Fill, + AutoScroll = true + }; + body.Controls.Add(pages[i]); + } + + BuildSelection(); + BuildOutput(); + BuildConfirmation(); + BuildProgress(); + status.Name = "mssqlStatus"; + status.AutoSize = true; + status.MaximumSize = new Size(950, 0); + status.Margin = new Padding(0, 12, 0, 10); + root.Controls.Add(status, 0, 2); + var footer = new FlowLayoutPanel + { + Name = "mssqlFooter", + AutoSize = true, + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + WrapContents = true + }; + Configure(cancel, "mssqlCancel", T("取消", "Cancel"), (s, e) => + { + if (busy) + RequestCancel(); + else + Close(); + }); + Configure(next, "mssqlStart", T("下一步 →", "Next →"), async (s, e) => + { + if (step == 0) + { + step = 1; + ShowPage(); + } + else if (step == 1) + { + step = 2; + ShowPage(); + await PreflightAsync(); + } + else if (step == 2) + await ExportAsync(); + else + { + InvalidateRequest(); + step = 0; + ShowPage(); + } + }); + Configure(back, "mssqlBack", T("← 上一步", "← Back"), (s, e) => + { + InvalidateRequest(); + step--; + ShowPage(); + }); + Configure(openFile, "mssqlOpenFile", T("開啟檔案", "Open file"), (s, e) => Open(Result?.PublishedPath)); + Configure(openFolder, "mssqlOpenFolder", T("開啟資料夾", "Open folder"), (s, e) => Open(Result?.PublishedPath == null ? null : Path.GetDirectoryName(Result.PublishedPath))); + footer.Controls.AddRange(new Control[] { cancel, next, back, openFolder, openFile }); + root.Controls.Add(footer, 0, 3); + ThemeManager.MarkAsPrimary(next); + AcceptButton = next; + CancelButton = cancel; + Shown += async (s, e) => + { + var work = Screen.FromControl(Owner ?? this).WorkingArea; + MinimumSize = new Size(Math.Min(MinimumSize.Width, work.Width), Math.Min(MinimumSize.Height, work.Height)); + Size = new Size(Math.Min(Width, work.Width), Math.Min(Height, work.Height)); + Location = new Point(Math.Max(work.Left, Math.Min(Left, work.Right - Width)), Math.Max(work.Top, Math.Min(Top, work.Bottom - Height))); + await LoadAsync(); + }; + FormClosing += (s, e) => + { + if (busy) + { + e.Cancel = true; + closing = true; + RequestCancel(); + } + }; + timer.Tick += (s, e) => UpdateClock(); + ThemeManager.ApplyTo(this); + percent.ForeColor = ThemeManager.AccentColor; + ShowPage(); + } + + private static FlowLayoutPanel Stack() + { + return new FlowLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false + }; + } + + private static Label Label(string text, Font font = null) + { + return new Label + { + Text = text, + AutoSize = true, + Font = font ?? UiKit.Body, + Margin = new Padding(0, 6, 0, 8) + }; + } + + private static void Configure(Button button, string name, string text, EventHandler handler) + { + button.Name = name; + button.Text = text; + button.AccessibleName = text; + button.AutoSize = true; + button.MinimumSize = new Size(90, 36); + button.Click += handler; + } + + private static void Grid(DataGridView grid) + { + grid.Dock = DockStyle.Fill; + grid.AllowUserToAddRows = false; + grid.AllowUserToDeleteRows = false; + grid.RowHeadersVisible = false; + grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill; + grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + grid.BackgroundColor = ThemeManager.SurfaceColor; + grid.BorderStyle = BorderStyle.None; + grid.AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.AllCells; + } + + private void BuildSelection() + { + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 3 + }; + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + pages[0].Controls.Add(layout); + var tools = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true + }; + search.Name = "mssqlTableSearch"; + search.Width = 260; + search.AccessibleName = T("搜尋 schema、資料表與註解", "Search schema, table and comment"); + tools.Controls.Add(Label(T("搜尋資料表", "Search tables"))); + tools.Controls.Add(search); + var all = new Button(); + var none = new Button(); + Configure(all, "mssqlSelectAll", T("全選", "Select all"), (s, e) => + { + foreach (var t in catalog) + selected.Add(t.Key.ObjectId); + InvalidateRequest(); + RefreshTables(); + }); + Configure(none, "mssqlSelectNone", T("全部取消", "Clear all"), (s, e) => + { + selected.Clear(); + InvalidateRequest(); + RefreshTables(); + }); + tools.Controls.Add(all); + tools.Controls.Add(none); + layout.Controls.Add(tools, 0, 0); + Grid(tables); + tables.Name = "mssqlTableList"; + tables.Columns.Add(new DataGridViewCheckBoxColumn { Name = "selected", HeaderText = T("選取", "Select"), FillWeight = 35 }); + foreach (var text in new[] + { + "Schema", + T("資料表", "Table"), + T("註解", "Comment"), + T("估計筆數", "Est. rows"), + T("估計大小", "Est. bytes") + } + + ) + { + int index = tables.Columns.Add(text, text); + tables.Columns[index].ReadOnly = true; + } + + tables.CurrentCellDirtyStateChanged += (s, e) => + { + if (tables.IsCurrentCellDirty) + tables.CommitEdit(DataGridViewDataErrorContexts.Commit); + }; + tables.CellValueChanged += (s, e) => + { + if (rebuilding || e.RowIndex < 0 || e.ColumnIndex != 0) + return; + var key = (MssqlObjectKey)tables.Rows[e.RowIndex].Tag; + if (Convert.ToBoolean(tables.Rows[e.RowIndex].Cells[0].Value)) + selected.Add(key.ObjectId); + else + selected.Remove(key.ObjectId); + InvalidateRequest(); + UpdateButtons(); + }; + search.TextChanged += (s, e) => RefreshTables(); + layout.Controls.Add(tables, 0, 1); + selection.AutoSize = true; + layout.Controls.Add(selection, 0, 2); + } + + private void RefreshTables() + { + rebuilding = true; + tables.Rows.Clear(); + foreach (var t in catalog) + if ((t.Key.Schema + " " + t.Key.Name + " " + t.Comment).IndexOf(search.Text, StringComparison.OrdinalIgnoreCase) >= 0) + { + int i = tables.Rows.Add(selected.Contains(t.Key.ObjectId), t.Key.Schema, t.Key.Name, t.Comment, t.EstimatedRows?.ToString("N0") ?? "—", t.EstimatedBytes?.ToString("N0") ?? "—"); + tables.Rows[i].Tag = t.Key; + } + + rebuilding = false; + UpdateButtons(); + } + + private void BuildOutput() + { + var content = Stack(); + pages[1].Controls.Add(content); + content.Controls.Add(Label(T("輸出設定", "Output settings"), UiKit.Subtitle)); + var radios = new[] + { + schemaData, + schemaOnly, + dataOnly + }; + var names = new[] + { + "mssqlSchemaAndData", + "mssqlSchemaOnly", + "mssqlDataOnly" + }; + var titles = new[] + { + T("結構與資料", "Schema and data"), + T("僅結構", "Schema only"), + T("僅資料", "Data only") + }; + for (int i = 0; i < 3; i++) + { + var radio = radios[i]; + radio.Name = names[i]; + radio.Text = titles[i]; + radio.AutoSize = true; + content.Controls.Add(radio); + radio.CheckedChanged += (s, e) => + { + if (!radio.Checked) + return; + views.Enabled = !dataOnly.Checked; + if (dataOnly.Checked) + views.Checked = false; + InvalidateRequest(); + UpdateButtons(); + }; + } + + views.Name = "mssqlIncludeViews"; + views.Text = T("包含檢視表定義(結構模式)", "Include view definitions (schema modes)"); + views.AutoSize = true; + views.Checked = options.IncludeViews; + views.CheckedChanged += (s, e) => + { + InvalidateRequest(); + UpdateButtons(); + }; + content.Controls.Add(views); + radios[(int)options.Mode].Checked = true; + content.Controls.Add(Label(T("SQL 輸出路徑", "SQL output path"))); + var path = new FlowLayoutPanel + { + AutoSize = true, + Width = 850 + }; + output.Name = "mssqlOutputPath"; + output.ReadOnly = true; + output.Width = 580; + output.AccessibleName = T("SQL 輸出路徑", "SQL output path"); + output.Text = !string.IsNullOrWhiteSpace(options.DefaultFileName) && Path.IsPathRooted(options.DefaultFileName) ? options.DefaultFileName : ""; + output.TextChanged += (s, e) => + { + InvalidateRequest(); + UpdateButtons(); + }; + path.Controls.Add(output); + var browse = new Button(); + Configure(browse, "mssqlBrowse", T("瀏覽…", "Browse…"), (s, e) => + { + using (var dialog = new SaveFileDialog + { + Filter = "SQL (*.sql)|*.sql", + DefaultExt = "sql", + AddExtension = true, + OverwritePrompt = false, + FileName = string.IsNullOrWhiteSpace(output.Text) ? options.DefaultFileName ?? database + ".sql" : output.Text + } + + ) + if (dialog.ShowDialog(this) == DialogResult.OK) + output.Text = dialog.FileName; + }); + path.Controls.Add(browse); + content.Controls.Add(path); + content.Controls.Add(Label(options.Mirror == null ? T("鏡像:未啟用", "Mirror: disabled") : T("鏡像:", "Mirror: ") + options.Mirror.Directory + " · " + options.Mirror.RetainCount)); + content.Controls.Add(Label(T("SQL 邏輯匯出;不包含登入帳號、作業或原生 .bak 備份。", "Logical SQL export; excludes logins, jobs and native .bak backups."))); + } + + private void BuildConfirmation() + { + var content = Stack(); + pages[2].Controls.Add(content); + content.Controls.Add(Label(T("確認匯出與預檢", "Confirm export and preflight"), UiKit.Subtitle)); + confirmation.Name = "mssqlConfirmation"; + confirmation.AutoSize = true; + confirmation.MaximumSize = new Size(900, 0); + content.Controls.Add(confirmation); + } + + private void BuildProgress() + { + var content = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 2, + RowCount = 4 + }; + content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 72)); + content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 28)); + pages[3].Controls.Add(content); + var stats = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + ColumnCount = 1 + }; + content.Controls.Add(stats, 0, 0); + foreach (var label in new[] + { + stage, + current, + percent, + written, + tableRows, + elapsed, + speed, + eta + } + + ) + { + label.AutoSize = true; + label.Margin = new Padding(0, 7, 0, 7); + label.MaximumSize = new Size(720, 0); + } + + stage.Name = "mssqlStage"; + stage.Font = UiKit.Subtitle; + current.Name = "mssqlCurrentTable"; + percent.Font = UiKit.Display; + written.Name = "mssqlWritten"; + elapsed.Name = "mssqlElapsed"; + speed.Name = "mssqlSpeed"; + eta.Name = "mssqlEta"; + overall.Name = "mssqlOverallProgress"; + overall.Dock = DockStyle.Top; + overall.Height = 10; + tableProgress.Name = "mssqlTableProgress"; + tableProgress.Dock = DockStyle.Top; + tableProgress.Height = 6; + foreach (Control c in new Control[] + { + stage, + current, + percent, + written, + overall, + tableRows, + tableProgress, + elapsed, + speed, + eta + } + + ) + stats.Controls.Add(c); + var mascot = Stack(); + mascot.Dock = DockStyle.Top; + punky.Name = "mssqlPunky"; + punky.Size = new Size(240, 240); + punky.Muted = false; + mascot.Controls.Add(punky); + Configure(sound, "mssqlToggleSound", T("靜音", "Mute"), (s, e) => + { + punky.Muted = !punky.Muted; + sound.Text = punky.Muted ? T("開啟聲音", "Unmute") : T("靜音", "Mute"); + sound.AccessibleName = sound.Text; + }); + sound.Enabled = punky.AudioAvailable; + sound.AccessibleDescription = T("原音只播放一次;取消靜音不會重播。", "Original audio plays once; unmuting does not replay it."); + mascot.Controls.Add(sound); + mascot.Controls.Add(Label(T("正在幫你整理資料!", "Organizing your data!"))); + content.Controls.Add(mascot, 1, 0); + var flow = Label(T("準備 › 結構 › 資料 › 驗證 › 發布 › 鏡像", "Prepare › Schema › Data › Verify › Publish › Mirror")); + content.Controls.Add(flow, 0, 1); + content.SetColumnSpan(flow, 2); + Grid(queue); + queue.Name = "mssqlTableQueue"; + queue.Height = 150; + queue.Dock = DockStyle.Top; + queue.ReadOnly = true; + foreach (var text in new[] + { + T("資料表", "Table"), + T("註解", "Comment"), + T("狀態", "Status") + } + + ) + queue.Columns.Add(text, text); + content.Controls.Add(queue, 0, 2); + content.SetColumnSpan(queue, 2); + var lower = Stack(); + lower.Dock = DockStyle.Top; + resultText.AutoSize = true; + resultText.MaximumSize = new Size(900, 0); + resultText.Name = "mssqlResult"; + lower.Controls.Add(resultText); + var detail = new Button(); + Configure(detail, "mssqlDetails", T("詳細記錄", "Details"), (s, e) => log.Visible = !log.Visible); + lower.Controls.Add(detail); + log.Name = "mssqlLog"; + log.Width = 850; + log.Height = 120; + log.Visible = false; + lower.Controls.Add(log); + content.Controls.Add(lower, 0, 3); + content.SetColumnSpan(lower, 2); + pages[3].Resize += (s, e) => + { + punky.Size = new Size(Math.Max(100, Math.Min(240, pages[3].ClientSize.Width / 4)), Math.Max(100, Math.Min(240, pages[3].ClientSize.Width / 4))); + }; + } + + private MssqlExportMode Mode => dataOnly.Checked ? MssqlExportMode.DataOnly : schemaOnly.Checked ? MssqlExportMode.SchemaOnly : MssqlExportMode.SchemaAndData; + + private void InvalidateRequest() + { + generation++; + prepared = null; + Result = null; + last = null; + resultText.Text = ""; + openFile.Visible = openFolder.Visible = false; + } + + private void ShowPage() + { + for (int i = 0; i < pages.Length; i++) + { + pages[i].Visible = i == step; + if (i == step) + pages[i].BringToFront(); + } + + steps.Text = T("① 選擇資料表 → ② 輸出設定 → ③ 確認匯出 → ④ 執行與結果", "① Tables → ② Output → ③ Confirm → ④ Progress and result"); + UpdateButtons(); + } + + private void UpdateButtons() + { + selection.Text = T("已選取 ", "Selected ") + selected.Count + " / " + catalog.Count; + back.Visible = step > 0 && step < 3; + back.Enabled = !busy; + next.Visible = step < 3 || (!busy && Result != null); + next.Enabled = !busy && loaded && (step == 0 ? selected.Count > 0 : step == 1 ? !string.IsNullOrWhiteSpace(output.Text) : step == 2 ? prepared != null : true); + next.Text = step == 2 ? T("開始匯出", "Start export") : step == 3 ? T("重新設定", "Reset") : T("下一步 →", "Next →"); + cancel.Enabled = !cancelRequested; + cancel.Text = busy && exporting ? T("取消匯出", "Cancel export") : T("關閉", "Close"); + next.AccessibleName = next.Text; + cancel.AccessibleName = cancel.Text; + openFile.Visible = openFolder.Visible = !busy && Result?.PublishedPath != null; + for (int i = 0; i < 3; i++) + pages[i].Enabled = !busy; + } + + private void BeginOperation() + { + busy = true; + cancelRequested = false; + cancellation = new CancellationTokenSource(); + UpdateButtons(); + } + + private void EndOperation() + { + busy = false; + exporting = false; + cancelRequested = false; + cancellation.Dispose(); + cancellation = null; + UpdateButtons(); + if (closing) + Close(); + } + + private async Task LoadAsync() + { + BeginOperation(); + status.Text = T("讀取原生目錄…", "Loading native catalog…"); + try + { + var value = await Task.Run(() => service.LoadCatalog(database, cancellation.Token)); + if (closing) + return; + catalog = value.Tables; + selected.Clear(); + if (options.InitialObjectName != null) + { + var matches = catalog.Where(t => (t.Key.Schema == "dbo" ? t.Key.Name : t.Key.Schema + "." + t.Key.Name) == options.InitialObjectName).ToList(); + if (matches.Count == 1) + selected.Add(matches[0].Key.ObjectId); + } + else + foreach (var table in catalog) + if (options.InitialTables == null || options.InitialTables.Any(k => k.Schema == table.Key.Schema && k.Name == table.Key.Name)) + selected.Add(table.Key.ObjectId); + loaded = true; + status.Text = selected.Count == 0 ? T("請選取資料表;初始物件可能已移除或名稱不唯一。", "Select tables; the initial object may be missing or ambiguous.") : T("估計筆數與大小來自目錄,可能與實際值不同。", "Catalog row and size estimates may differ from actual values."); + RefreshTables(); + } + catch (OperationCanceledException) + { + status.Text = T("已取消", "Canceled"); + } + catch (Exception) + { + status.Text = T("無法讀取目錄,請關閉後重試。", "Cannot load catalog. Close and retry."); + } + finally + { + EndOperation(); + } + } + + private async Task PreflightAsync() + { + InvalidateRequest(); + int version = generation; + var keys = catalog.Where(t => selected.Contains(t.Key.ObjectId)).Select(t => t.Key).ToList().AsReadOnly(); + var mode = Mode; + var include = views.Checked; + var path = output.Text; + BeginOperation(); + confirmation.Text = T("正在捕獲目的檔案狀態並預檢…", "Capturing destination and checking preflight…"); + try + { + var stamp = await Task.Run(() => MssqlExportArtifact.CaptureDestination(path, cancellation.Token)); + if (closing || version != generation) + return; + if (stamp.Exists && MessageBox.Show(this, T("目的檔案已存在。確認覆寫這個檔案?", "Destination exists. Replace this file?"), Text, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) + { + confirmation.Text = T("未同意覆寫。", "Replacement not approved."); + return; + } + + var request = new MssqlExportRequest(Guid.NewGuid(), database, keys, mode, include, path, stamp, options.Mirror); + var check = await Task.Run(() => service.Preflight(request, cancellation.Token)); + if (closing || version != generation) + return; + confirmation.Text = T("SQL 邏輯匯出;不包含登入帳號、作業、原生備份。", "Logical SQL export; excludes logins, jobs and native backups.") + "\r\n\r\n" + keys.Count + T(" 張資料表", " tables") + " · " + ModeText(mode) + "\r\n" + request.TargetPath + "\r\n" + (options.Mirror == null ? T("鏡像:未啟用", "Mirror: disabled") : T("鏡像:", "Mirror: ") + options.Mirror.Directory) + "\r\n" + (mode == MssqlExportMode.SchemaOnly ? T("僅結構:不開啟資料快照交易。", "Schema only: no data snapshot transaction.") : "SNAPSHOT: " + (check.SnapshotState == 1 ? "ON" : "OFF") + T(";全部資料使用同一交易。", "; all data uses one transaction.")) + "\r\n\r\n" + string.Join("\r\n", check.Issues.Select(x => x.Message)); + if (check.CanStart && (mode == MssqlExportMode.SchemaOnly || check.SnapshotState == 1)) + prepared = request; + status.Text = prepared != null ? T("預檢通過;開始時會再次檢查來源。", "Preflight passed; source is checked again at start.") : T("預檢未通過,無法開始。", "Preflight failed; start is blocked."); + } + catch (OperationCanceledException) + { + status.Text = T("已取消", "Canceled"); + } + catch (Exception) + { + status.Text = T("目的路徑或預檢失敗,請返回檢查設定。", "Destination or preflight failed. Go back and check settings."); + } + finally + { + EndOperation(); + } + } + + private async Task ExportAsync() + { + if (prepared == null) + return; + var request = prepared; + int version = generation; + Result = null; + last = null; + log.Items.Clear(); + queue.Rows.Clear(); + processed.Clear(); + foreach (var t in catalog.Where(t => selected.Contains(t.Key.ObjectId))) + { + int row = queue.Rows.Add(t.Key.Schema + "." + t.Key.Name, t.Comment, T("等待中", "Waiting")); + queue.Rows[row].Tag = t.Key.ObjectId; + } + stage.Text = StageText(MssqlExportStage.Preflight); + current.Text = ""; + percent.Text = T("估算中", "Estimating"); + written.Text = T("已寫入 0 bytes", "Written 0 bytes"); + overall.Value = tableProgress.Value = 0; + overall.Style = tableProgress.Style = ProgressBarStyle.Marquee; + tableRows.Text = ""; + resultText.Text = ""; + step = 3; + ShowPage(); + BeginOperation(); + exporting = true; + clock.Restart(); + timer.Start(); + punky.BeginExport(); + UpdateButtons(); + var progress = new Progress(p => + { + if (!IsDisposed && !closing && busy && exporting && version == generation && p.JobId == request.JobId) + RenderProgress(p); + }); + try + { + var result = await Task.Run(() => service.Export(request, progress, cancellation.Token)); + if (result.JobId == request.JobId) + { + Result = result; + stage.Text = StageText(result.Stage); + status.Text = StageText(result.Stage); + foreach (DataGridViewRow row in queue.Rows) + { + bool done = processed.Contains((int)row.Tag) || result.RowCounts.Any(r => r.Key.ObjectId == (int)row.Tag) || result.Stage == MssqlExportStage.Completed; + row.Cells[2].Value = done ? T("已處理", "Processed") : result.Stage == MssqlExportStage.Canceled ? T("已取消", "Canceled") : T("未完成", "Incomplete"); + } + resultText.Text = (result.PublishedPath == null ? T("沒有已發布的本機檔案。", "No local file was published.") : T("已發布:", "Published: ") + result.PublishedPath) + "\r\n" + (result.MirrorPath ?? "") + "\r\n" + string.Join("\r\n", result.Warnings.Concat(result.ResidualPaths)) + "\r\n" + (result.ErrorMessage ?? "") + "\r\n" + T("檔案驗證不等同還原驗收。請使用支援此檔案大小的客戶端,以 UTF-8、停用變數替代、遇錯停止,於同一工作階段還原。詳見 docs/MSSQL_SQL_EXPORT.md。", "File verification is not restore acceptance. Use a client supporting this file size: UTF-8, variable substitution disabled, stop on error, and one restore session. See docs/MSSQL_SQL_EXPORT.md."); + if (result.Stage == MssqlExportStage.Completed) + { + overall.Style = ProgressBarStyle.Continuous; + overall.Value = 100; + percent.Text = "100%"; + } + } + } + catch (Exception) + { + status.Text = T("匯出作業失敗。", "Export failed."); + stage.Text = StageText(MssqlExportStage.Failed); + } + finally + { + timer.Stop(); + clock.Stop(); + punky.EndExport(); + EndOperation(); + } + } + + private void RenderProgress(MssqlExportProgress p) + { + if (last?.CurrentTable != null && p.CompletedTables > last.CompletedTables) + processed.Add(last.CurrentTable.ObjectId); + last = p; + stage.Text = cancelRequested ? StageText(MssqlExportStage.CancelRequested) : StageText(p.Stage); + current.Text = p.CurrentTable == null ? "" : p.CurrentTable.Schema + "." + p.CurrentTable.Name; + percent.Text = p.Percent.HasValue ? Math.Min(99, p.Percent.Value).ToString("0") + "%" : T("估算中", "Estimating"); + overall.Style = p.Percent.HasValue ? ProgressBarStyle.Continuous : ProgressBarStyle.Marquee; + if (p.Percent.HasValue) + overall.Value = (int)Math.Max(0, Math.Min(99, p.Percent.Value)); + written.Text = T("已寫入 ", "Written ") + p.WrittenBytes.ToString("N0") + " bytes"; + var table = catalog.FirstOrDefault(t => t.Key.ObjectId == p.CurrentTable?.ObjectId); + var estimate = table?.EstimatedRows; + tableRows.Text = T("目前資料表:", "Current table: ") + p.CurrentTableRows.ToString("N0") + " / " + (estimate?.ToString("N0") ?? "—"); + tableProgress.Style = estimate > 0 ? ProgressBarStyle.Continuous : ProgressBarStyle.Marquee; + if (estimate > 0) + tableProgress.Value = (int)Math.Min(100, 100.0 * p.CurrentTableRows / estimate.Value); + if (p.CurrentTable == null && p.PhaseTotalBytes > 0) + { + tableRows.Text = T("本階段檔案處理:", "Current file phase: ") + p.PhaseProcessedBytes.ToString("N0") + " / " + p.PhaseTotalBytes.Value.ToString("N0") + " bytes"; + tableProgress.Style = ProgressBarStyle.Continuous; + tableProgress.Value = (int)Math.Min(100, 100.0 * p.PhaseProcessedBytes / p.PhaseTotalBytes.Value); + } + for (int i = 0; i < queue.Rows.Count; i++) + queue.Rows[i].Cells[2].Value = processed.Contains((int)queue.Rows[i].Tag) ? T("已處理", "Processed") : p.CurrentTable != null && (int)queue.Rows[i].Tag == p.CurrentTable.ObjectId ? T("匯出中", "Exporting") : T("等待中", "Waiting"); + if (!string.IsNullOrWhiteSpace(p.Message)) + { + log.Items.Add(p.Message); + while (log.Items.Count > MssqlExportLimits.ProgressLogLimit) + log.Items.RemoveAt(0); + log.TopIndex = log.Items.Count - 1; + } + + UpdateClock(); + } + + private void UpdateClock() + { + elapsed.Text = T("已經過:", "Elapsed: ") + clock.Elapsed.ToString(@"hh\:mm\:ss"); + bool stale = last == null || clock.Elapsed - last.LastProgressElapsed >= TimeSpan.FromSeconds(5) || cancelRequested; + speed.Text = T("本階段速度:", "Phase rate: ") + (!stale && last.BytesPerSecond.HasValue ? last.BytesPerSecond.Value.ToString("N0") + " bytes/s" : "—"); + eta.Text = T("本階段預估:", "Phase ETA: ") + (!stale && last.Eta.HasValue ? last.Eta.Value.ToString(@"hh\:mm\:ss") : T("等待/估算中", "Waiting / estimating")); + } + + private void RequestCancel() + { + if (cancellation == null || cancelRequested) + return; + cancelRequested = true; + cancellation.Cancel(); + status.Text = T("正在取消,等待查詢停止及清理完成…", "Canceling; waiting for query and cleanup…"); + stage.Text = StageText(MssqlExportStage.CancelRequested); + UpdateButtons(); + UpdateClock(); + } + + private static string ModeText(MssqlExportMode mode) + { + return mode == MssqlExportMode.SchemaOnly ? T("僅結構", "Schema only") : mode == MssqlExportMode.DataOnly ? T("僅資料", "Data only") : T("結構與資料", "Schema and data"); + } + + private static string StageText(MssqlExportStage value) + { + switch (value) + { + case MssqlExportStage.Preflight: + return T("準備與預檢", "Preparing and checking"); + case MssqlExportStage.Schema: + return T("正在匯出結構", "Exporting schema"); + case MssqlExportStage.Data: + return T("正在匯出資料", "Exporting data"); + case MssqlExportStage.Verifying: + return T("正在驗證檔案", "Verifying file"); + case MssqlExportStage.Publishing: + return T("正在發布檔案", "Publishing file"); + case MssqlExportStage.Mirroring: + return T("正在複製並驗證鏡像", "Copying and verifying mirror"); + case MssqlExportStage.CancelRequested: + return T("正在取消,等待清理", "Canceling; waiting for cleanup"); + case MssqlExportStage.Completed: + return T("匯出完成", "Export completed"); + case MssqlExportStage.Canceled: + return T("匯出已取消", "Export canceled"); + case MssqlExportStage.LocalCompletedMirrorIncomplete: + return T("本機完成;鏡像未完成", "Local completed; mirror incomplete"); + default: + return T("匯出失敗", "Export failed"); + } + } + + private void Open(string path) + { + if (path == null) + return; + try + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (Exception) + { + status.Text = T("無法開啟路徑。", "Cannot open path."); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + timer.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs index 48c78e5..5847d7d 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -55,6 +55,26 @@ public ExportDestinationStamp(bool exists, string fullPath, string fileIdentity, } } + // Null selection and null seed mean database scope. An explicit empty or unresolved + // selection never broadens to all tables. Raw seeds use the existing provider tree + // representation (dbo => name, other schemas => schema.name), not SQL parsing. + internal sealed class MssqlExportWizardOptions + { + public MssqlExportMode Mode { get; } + public IReadOnlyList InitialTables { get; } + public string InitialObjectName { get; } + public bool IncludeViews { get; } + public string DefaultFileName { get; } + public MssqlMirrorOptions Mirror { get; } + internal MssqlExportWizardOptions(MssqlExportMode mode, IReadOnlyList initialTables = null, bool includeViews = true, string defaultFileName = null, MssqlMirrorOptions mirror = null, string initialObjectName = null) + { + if (!Enum.IsDefined(typeof(MssqlExportMode), mode)) throw new ArgumentOutOfRangeException(nameof(mode)); + Mode = mode; InitialTables = initialTables == null ? null : new List(initialTables).AsReadOnly(); + if (InitialTables != null && InitialTables.Any(x => x == null)) throw new ArgumentException("Null initial table", nameof(initialTables)); + InitialObjectName = initialObjectName; IncludeViews = includeViews; DefaultFileName = defaultFileName; Mirror = mirror; + } + } + internal sealed class MssqlExportRequest { public Guid JobId { get; } diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 46a9a6b..3266630 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -297,6 +297,7 @@ + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index ba9de73..c3447a9 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Worker.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Wizard.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Wizard.cs b/tests/SmokeTests.MssqlExport.Wizard.cs new file mode 100644 index 0000000..63c457e --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Wizard.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows.Forms; +using mySQLPunk; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static void Wizard_RequiresForm() + { + Assert(typeof(MssqlObjectKey).Assembly.GetType("mySQLPunk.MssqlExportWizardForm") != null, "Native export wizard must exist"); + } + + private static void WClick(Form form, string name) + { + // DoEvents ends its temporary loop and may uninstall the WinForms context. + // Real Application.Run retains it; restore it before invoking async clicks. + SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); + WControl