From ca51b6dbef9b711d83843bdd9c308fa364b4d43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:32:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20SQL=20Server=20?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E6=96=B0=E5=A2=9E=E7=9A=84=E5=8F=83=E6=95=B8?= =?UTF-8?q?=E8=88=87=E5=88=97=E6=95=B8=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本 1,000 列三欄會產生 3,000 個參數,超過 SQL Server 上限。改為同時依參數數量與 VALUES 列數切批,整次新增使用同一交易,避免中途失敗留下前批資料而讓重試重複。 離線 SQLite 覆蓋 699、700、701、1,000、1,001 列邊界、第二批失敗回滾及重試;跨平台 smoke 已通過。Windows 整體編譯與 smoke 將由 PR CI 驗證。 --- CHANGELOG.md | 3 + mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 9 +++ .../mySQLPunk.CrossPlatform.SmokeTests.csproj | 2 + mySQLPunk/lib/SqlServerBatchInsert.cs | 66 +++++++++++++++++++ mySQLPunk/lib/my_mssql.cs | 18 +---- mySQLPunk/mySQLPunk.csproj | 3 +- tests/Run-SmokeTests.ps1 | 1 + tests/SmokeTests.cs | 5 ++ tests/SqlServerBatchInsertTests.cs | 57 ++++++++++++++++ 9 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 mySQLPunk/lib/SqlServerBatchInsert.cs create mode 100644 tests/SqlServerBatchInsertTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b7b634..75ff4061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🚀 新增功能 + - **系統資料庫規格書樣版(Windows,待最終匯出驗收)**:DOCX 新增第三種樣版,提供 A4 直式資料表清單、主鍵與索引摘要、淡藍色可編輯欄位表,以及 PK/NN 和長度/精度欄;中文名稱沿用資料庫註解。 - **Windows 建置與啟動腳本**:根目錄新增 `build.bat` 與 `run.bat`,支援 `debug` / `release` 且預設 Debug;自動尋找 MSBuild 並還原套件,`run.bat` 只在編譯成功後啟動對應版本。 - **匯出/匯入工作區(協同合作第一階段,Windows)**:「檔案」選單新增「匯出工作區...」與「匯入工作區...」。匯出把目前設定檔的連線(含群組、星號與顏色;一律移除密碼、SSH/SSL 祕密、權杖與本機憑證參照,登入帳號預設不含)、SQL 片段、AI 動作、自動執行作業(Webhook 網址可能含權杖,預設不含)與自訂資料產生器字典寫成資料夾:JSON 鍵排序、固定順序、LF 換行、沒有時間戳記,重複匯出內容完全相同,並附 `.gitattributes`,可直接提交到 Git 與隊友共用。匯入先比較每個項目為「新增/變更/相同」並讓你勾選要套用的項目;自動執行作業取代時保留本機的 Webhook;接著開啟既有的連線匯入預覽,工作區的連線一律以合併方式匯入並沿用本機密碼,沒有帳號的連線會對應到本機相同的連線而不重複新增。 @@ -51,6 +52,8 @@ ### 🛠️ 問題修正與優化 +- SQL Server 批次新增依 2,100 參數與 1,000 VALUES 列上限切批,整次呼叫共用交易,中途失敗不留下前批資料。 + - Windows 資料庫報表精靈的資料表清單顯示並可搜尋註解,PDF 標準目錄同步附上表註解;Punky 移到確認頁並放大,開始匯出自動循環、首輪播放原音效,提供靜音。樣板放大預覽改用高解析圖片,支援符合寬度、捲動、縮放;返回修改選項時會清除過期的完成狀態。 - Windows 資料同步寫入 PostgreSQL `SERIAL` 主鍵的明確值後不會推進序列,之後以預設值新增資料列可能撞主鍵;現在 identity 與 `DEFAULT nextval` 欄位寫入明確值後都會把序列移到最大值之後。 - Linux/macOS 連 MySQL/MariaDB 時,`CHAR(36)` 欄位只要存了非 GUID 文字,整張資料表就無法瀏覽(驅動程式預設把 `CHAR(36)` 當 GUID 解析);現在一律以一般字串讀寫,`BINARY(16)` 也維持位元組。 diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 895ef0a6..d9e106ca 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -11,6 +11,7 @@ var tests = new List<(string Name, Func Run)> { + ("SQL Server 分批交易與重試", BatchInsertAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), ("連線 URI 安全匯入", ConnectionUriImportAsync), ("TLS 憑證檔案安全套用", TlsCertificateFilesAsync), @@ -37,6 +38,14 @@ ("Provider 驗證與工廠", ProviderFactoryValidatesProfilesAsync) }; +static Task BatchInsertAsync() +{ + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + connection.Open(); + SqlServerBatchInsertTests.Run(connection); + return Task.CompletedTask; +} + static Task ConnectionUriImportAsync() { var mysql = ConnectionUriImportService.Parse( diff --git a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj index 12a0a4cd..e7ccb9d9 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj +++ b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj @@ -10,6 +10,8 @@ + + diff --git a/mySQLPunk/lib/SqlServerBatchInsert.cs b/mySQLPunk/lib/SqlServerBatchInsert.cs new file mode 100644 index 00000000..f089cf78 --- /dev/null +++ b/mySQLPunk/lib/SqlServerBatchInsert.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; + +namespace mySQLPunk.lib +{ + public static class SqlServerBatchInsert + { + public static int RowsPerCommand(int columnCount) + { + if (columnCount < 1 || columnCount > 2100) + throw new ArgumentOutOfRangeException("columnCount"); + return Math.Min(1000, 2100 / columnCount); + } + + public static void Execute(DbConnection connection, string qualifiedTable, DataTable rows) + { + if (rows == null || rows.Rows.Count == 0) return; + RowsPerCommand(rows.Columns.Count); + using (DbTransaction transaction = connection.BeginTransaction()) + { + Execute(connection, qualifiedTable, rows, transaction); + transaction.Commit(); + } + } + + public static void Execute(DbConnection connection, string qualifiedTable, DataTable rows, DbTransaction transaction) + { + if (transaction == null || transaction.Connection != connection) + throw new ArgumentException("必須提供同一連線的有效交易。", "transaction"); + if (rows == null || rows.Rows.Count == 0) return; + int batchSize = RowsPerCommand(rows.Columns.Count); + List columns = new List(); + foreach (DataColumn column in rows.Columns) + columns.Add("[" + column.ColumnName.Replace("]", "]]") + "]"); + for (int offset = 0; offset < rows.Rows.Count; offset += batchSize) + { + using (DbCommand command = connection.CreateCommand()) + { + command.Transaction = transaction; + List groups = new List(); + int count = Math.Min(batchSize, rows.Rows.Count - offset); + for (int rowIndex = 0; rowIndex < count; rowIndex++) + { + List values = new List(); + for (int columnIndex = 0; columnIndex < rows.Columns.Count; columnIndex++) + { + string name = "@p" + rowIndex + "_" + columnIndex; + values.Add(name); + DbParameter parameter = command.CreateParameter(); + parameter.ParameterName = name; + if (rows.Columns[columnIndex].DataType == typeof(DateTime)) parameter.DbType = DbType.DateTime2; + if (rows.Columns[columnIndex].DataType == typeof(TimeSpan)) parameter.DbType = DbType.Time; + parameter.Value = rows.Rows[offset + rowIndex][columnIndex]; + command.Parameters.Add(parameter); + } + groups.Add("(" + string.Join(",", values) + ")"); + } + command.CommandText = "INSERT INTO " + qualifiedTable + " (" + string.Join(",", columns) + ") VALUES " + string.Join(",", groups) + ";"; + command.ExecuteNonQuery(); + } + } + } + } +} diff --git a/mySQLPunk/lib/my_mssql.cs b/mySQLPunk/lib/my_mssql.cs index c8939710..ed0792ee 100644 --- a/mySQLPunk/lib/my_mssql.cs +++ b/mySQLPunk/lib/my_mssql.cs @@ -603,23 +603,7 @@ SELECT kcu.COLUMN_NAME public void InsertTableBatch(string databaseName, string tableName, DataTable rows) { if (rows == null || rows.Rows.Count == 0) return; - List cols = new List(); - foreach (DataColumn col in rows.Columns) cols.Add("[" + EscapeSqlServerName(col.ColumnName) + "]"); - List valueGroups = new List(); - Dictionary p = new Dictionary(); - for (int r = 0; r < rows.Rows.Count; r++) - { - List vals = new List(); - for (int c = 0; c < rows.Columns.Count; c++) - { - string key = "p" + r + "_" + c; - vals.Add("@" + key); - p[key] = rows.Rows[r][c] == DBNull.Value ? DBNull.Value : rows.Rows[r][c]; - } - valueGroups.Add("(" + string.Join(",", vals.ToArray()) + ")"); - } - string sql = "INSERT INTO " + BuildSqlServerQualifiedName(databaseName, tableName) + " (" + string.Join(",", cols.ToArray()) + ") VALUES " + string.Join(",", valueGroups.ToArray()) + ";"; - ExecOrThrow(sql, p); + SqlServerBatchInsert.Execute(MCT, BuildSqlServerQualifiedName(databaseName, tableName), rows); } public string GetViewCreateStatement(string databaseName, string viewName) diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 02d7d5a6..e9301ce0 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -448,6 +448,7 @@ + @@ -838,4 +839,4 @@ - \ No newline at end of file + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index e7cbf596..f936873a 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,6 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` + (Join-Path $PSScriptRoot "SqlServerBatchInsertTests.cs") ` (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.TransferTrial.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Live.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Wizard.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Parser.cs") $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Snapshot.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.ViewReferences.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Properties.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") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Routing.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.SnapshotIntegration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.CompatibilityIntegration.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index d4dded33..73b2f108 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -13214,6 +13214,11 @@ private static void TestRedisTopologyOptions() private static void TestDataTransfer() { + using (var batchConnection = new System.Data.SQLite.SQLiteConnection("Data Source=:memory:")) + { + batchConnection.Open(); + SqlServerBatchInsertTests.Run(batchConnection); + } string dir = Path.Combine(Path.GetTempPath(), "mysqlpunk-transfer-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try diff --git a/tests/SqlServerBatchInsertTests.cs b/tests/SqlServerBatchInsertTests.cs new file mode 100644 index 00000000..b7585f96 --- /dev/null +++ b/tests/SqlServerBatchInsertTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Data; +using System.Data.Common; +using mySQLPunk.lib; + +public static class SqlServerBatchInsertTests +{ + public static void Run(DbConnection connection) + { + if (SqlServerBatchInsert.RowsPerCommand(1) != 1000 || SqlServerBatchInsert.RowsPerCommand(2) != 1000 || + SqlServerBatchInsert.RowsPerCommand(3) != 700 || SqlServerBatchInsert.RowsPerCommand(2100) != 1) + throw new Exception("SQL Server batch boundary"); + foreach (int columns in new[] { 0, 2101 }) + { + try { SqlServerBatchInsert.RowsPerCommand(columns); throw new Exception("Invalid column count accepted"); } + catch (ArgumentOutOfRangeException) { } + } + try { SqlServerBatchInsert.Execute(connection, "batch_test", new DataTable(), null); throw new Exception("Missing transaction accepted"); } + catch (ArgumentException) { } + using (DbCommand command = connection.CreateCommand()) + { + command.CommandText = "CREATE TABLE batch_test (id INTEGER PRIMARY KEY, value TEXT, optional TEXT)"; + command.ExecuteNonQuery(); + } + DataTable rows = new DataTable(); + rows.Columns.Add("id", typeof(int)); rows.Columns.Add("value", typeof(string)); rows.Columns.Add("optional", typeof(string)); + for (int index = 0; index < 1000; index++) rows.Rows.Add(index, "value", DBNull.Value); + rows.Rows[999]["id"] = 0; + try { SqlServerBatchInsert.Execute(connection, "batch_test", rows); throw new Exception("Duplicate accepted"); } + catch (DbException) { } + using (DbCommand command = connection.CreateCommand()) + { + command.CommandText = "SELECT COUNT(*) FROM batch_test"; + if (Convert.ToInt32(command.ExecuteScalar()) != 0) throw new Exception("Second batch failure did not roll back first batch"); + rows.Rows[999]["id"] = 999; + SqlServerBatchInsert.Execute(connection, "batch_test", rows); + if (Convert.ToInt32(command.ExecuteScalar()) != 1000) throw new Exception("Retry duplicated rows"); + foreach (int rowCount in new[] { 699, 700, 701, 1000, 1001 }) + { + command.CommandText = "DELETE FROM batch_test"; + command.ExecuteNonQuery(); + rows.Clear(); + for (int index = 0; index < rowCount; index++) rows.Rows.Add(index, "value", DBNull.Value); + SqlServerBatchInsert.Execute(connection, "batch_test", rows); + command.CommandText = "SELECT COUNT(*) FROM batch_test"; + if (Convert.ToInt32(command.ExecuteScalar()) != rowCount) throw new Exception("Three-column boundary " + rowCount); + } + command.CommandText = "CREATE TABLE batch_single (id INTEGER)"; + command.ExecuteNonQuery(); + var single = new DataTable(); single.Columns.Add("id", typeof(int)); + for (int index = 0; index < 1001; index++) single.Rows.Add(index); + SqlServerBatchInsert.Execute(connection, "batch_single", single); + command.CommandText = "SELECT COUNT(*) FROM batch_single"; + if (Convert.ToInt32(command.ExecuteScalar()) != 1001) throw new Exception("VALUES 1000-row boundary"); + } + } +} From 433d261f8015cf744c1e8ac02cde88a5ee1c67e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:33:33 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E9=87=8B=E6=94=BE=E9=80=A3=E7=B7=9A?= =?UTF-8?q?=E6=BA=96=E5=82=99=E5=A4=B1=E6=95=97=E6=99=82=E5=B0=9A=E6=9C=AA?= =?UTF-8?q?=E7=A7=BB=E4=BA=A4=E7=9A=84=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立 session 後,連線測試或取得資料庫失敗會留下連線資源與 SSH tunnel。新增暫時所有權 lease,成功且未取消才交給主視窗,其餘路徑一律釋放;密碼對話框返回後也再次確認沒有正在執行的操作。 mock 覆蓋準備失敗、取消與成功移交,確認 Dispose 次數。跨平台建置與 smoke 通過,隔離 HOME 的 SQLite UI 已確認可啟動。 --- CHANGELOG.md | 1 + mySQLPunk.Core/Services/SessionLease.cs | 23 ++++++++++++++ mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 30 +++++++++++++++++++ mySQLPunk.Desktop/MainWindow.axaml.cs | 9 ++++-- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 mySQLPunk.Core/Services/SessionLease.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ff4061..c260f4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ ### 🛠️ 問題修正與優化 - SQL Server 批次新增依 2,100 參數與 1,000 VALUES 列上限切批,整次呼叫共用交易,中途失敗不留下前批資料。 +- 桌面連線測試、取得資料庫或取消失敗時釋放尚未移交的 session,避免 SSH tunnel 洩漏。 - Windows 資料庫報表精靈的資料表清單顯示並可搜尋註解,PDF 標準目錄同步附上表註解;Punky 移到確認頁並放大,開始匯出自動循環、首輪播放原音效,提供靜音。樣板放大預覽改用高解析圖片,支援符合寬度、捲動、縮放;返回修改選項時會清除過期的完成狀態。 - Windows 資料同步寫入 PostgreSQL `SERIAL` 主鍵的明確值後不會推進序列,之後以預設值新增資料列可能撞主鍵;現在 identity 與 `DEFAULT nextval` 欄位寫入明確值後都會把序列移到最大值之後。 diff --git a/mySQLPunk.Core/Services/SessionLease.cs b/mySQLPunk.Core/Services/SessionLease.cs new file mode 100644 index 00000000..6184f7db --- /dev/null +++ b/mySQLPunk.Core/Services/SessionLease.cs @@ -0,0 +1,23 @@ +namespace MySqlPunk.Core.Services; + +public sealed class SessionLease(TSession session) : IDisposable where TSession : class, IDisposable +{ + private TSession? _session = session; + + public TSession Session => _session ?? throw new ObjectDisposedException(nameof(SessionLease)); + + public TSession Transfer(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var owned = Session; + _session = null; + return owned; + } + + public void Dispose() + { + var owned = _session; + _session = null; + owned?.Dispose(); + } +} diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index d9e106ca..aa865e4d 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -12,6 +12,7 @@ var tests = new List<(string Name, Func Run)> { ("SQL Server 分批交易與重試", BatchInsertAsync), + ("桌面 session 失敗與取消所有權", SessionOwnershipAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), ("連線 URI 安全匯入", ConnectionUriImportAsync), ("TLS 憑證檔案安全套用", TlsCertificateFilesAsync), @@ -46,6 +47,29 @@ static Task BatchInsertAsync() return Task.CompletedTask; } +static async Task SessionOwnershipAsync() +{ + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + foreach (var failure in new[] { "test", "databases", "cancel" }) + { + var session = new DisposableSession(); + try + { + using var lease = new SessionLease(session); + await Task.Yield(); + if (failure == "cancel") lease.Transfer(cancellation.Token); + throw new InvalidOperationException(failure); + } + catch (Exception exception) when (exception is InvalidOperationException or OperationCanceledException) { } + Assert(session.Disposals == 1, "Failed preparation disposes session exactly once"); + } + var owned = new DisposableSession(); + using (var lease = new SessionLease(owned)) lease.Transfer(CancellationToken.None); + Assert(owned.Disposals == 0, "Successful transfer retains session"); + owned.Dispose(); +} + static Task ConnectionUriImportAsync() { var mysql = ConnectionUriImportService.Parse( @@ -11196,3 +11220,9 @@ enum LiveSyncTarget TiDb, OceanBase } + +sealed class DisposableSession : IDisposable +{ + public int Disposals { get; private set; } + public void Dispose() => Disposals++; +} diff --git a/mySQLPunk.Desktop/MainWindow.axaml.cs b/mySQLPunk.Desktop/MainWindow.axaml.cs index 4c50eb84..f2a130e5 100644 --- a/mySQLPunk.Desktop/MainWindow.axaml.cs +++ b/mySQLPunk.Desktop/MainWindow.axaml.cs @@ -605,6 +605,7 @@ private async void DeleteProfile_Click(object? sender, RoutedEventArgs e) private async void ConnectProfile_Click(object? sender, RoutedEventArgs e) { + if (_operationCancellation is not null) return; if (_profilesList.SelectedItem is not ConnectionProfile selected) { return; @@ -616,14 +617,16 @@ private async void ConnectProfile_Click(object? sender, RoutedEventArgs e) return; } - Disconnect($"準備連線至 {connectionProfile.Name}…"); + if (_operationCancellation is not null) return; await RunOperationAsync($"正在連線至 {connectionProfile.Name}…", async cancellationToken => { - var session = DatabaseProviderFactory.Create(connectionProfile); + Disconnect($"準備連線至 {connectionProfile.Name}…"); + using var lease = new SessionLease(DatabaseProviderFactory.Create(connectionProfile)); + var session = lease.Session; await session.TestConnectionAsync(cancellationToken); var databases = await session.GetDatabasesAsync(cancellationToken); - _session = session; + _session = lease.Transfer(cancellationToken); _loadingDatabases = true; try { From 95f76610f30a5bb6487edd503058db2541a4e5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:33:33 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=8B=92=E7=B5=95=E9=87=8D=E8=A4=87?= =?UTF-8?q?=20SQL=20=E6=93=8D=E4=BD=9C=E4=B8=A6=E4=B8=9F=E6=A3=84=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E5=BE=8C=E7=9A=84=E7=B5=90=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本 RunOperationAsync 取消舊操作後立即重入,快捷鍵也能繞過 busy 狀態。改用共用互斥 gate,直到 provider 真正返回才釋放;SQL 與物件清單在發布前再次檢查取消,避免舊結果覆蓋畫面。 延遲完成的 mock 驗證重複操作被拒、取消期間不能重入、取消結果不發布,以及完成後可接受下一次操作。 --- CHANGELOG.md | 1 + mySQLPunk.Core/Services/OperationGate.cs | 22 +++++++++++++++ mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 28 +++++++++++++++++++ mySQLPunk.Desktop/MainWindow.axaml.cs | 9 ++++-- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 mySQLPunk.Core/Services/OperationGate.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c260f4bf..0a816a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - SQL Server 批次新增依 2,100 參數與 1,000 VALUES 列上限切批,整次呼叫共用交易,中途失敗不留下前批資料。 - 桌面連線測試、取得資料庫或取消失敗時釋放尚未移交的 session,避免 SSH tunnel 洩漏。 +- 桌面 SQL 快捷鍵與操作共用互斥 gate,取消後等待舊操作結束才接受新操作,取消結果不再覆蓋畫面。 - Windows 資料庫報表精靈的資料表清單顯示並可搜尋註解,PDF 標準目錄同步附上表註解;Punky 移到確認頁並放大,開始匯出自動循環、首輪播放原音效,提供靜音。樣板放大預覽改用高解析圖片,支援符合寬度、捲動、縮放;返回修改選項時會清除過期的完成狀態。 - Windows 資料同步寫入 PostgreSQL `SERIAL` 主鍵的明確值後不會推進序列,之後以預設值新增資料列可能撞主鍵;現在 identity 與 `DEFAULT nextval` 欄位寫入明確值後都會把序列移到最大值之後。 diff --git a/mySQLPunk.Core/Services/OperationGate.cs b/mySQLPunk.Core/Services/OperationGate.cs new file mode 100644 index 00000000..39bdbb72 --- /dev/null +++ b/mySQLPunk.Core/Services/OperationGate.cs @@ -0,0 +1,22 @@ +namespace MySqlPunk.Core.Services; + +public sealed class OperationGate +{ + private int _active; + + public IDisposable? TryEnter() + { + return Interlocked.CompareExchange(ref _active, 1, 0) == 0 ? new Lease(this) : null; + } + + private sealed class Lease(OperationGate owner) : IDisposable + { + private OperationGate? _owner = owner; + + public void Dispose() + { + var current = Interlocked.Exchange(ref _owner, null); + if (current is not null) Interlocked.Exchange(ref current._active, 0); + } + } +} diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index aa865e4d..789f1467 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -13,6 +13,7 @@ { ("SQL Server 分批交易與重試", BatchInsertAsync), ("桌面 session 失敗與取消所有權", SessionOwnershipAsync), + ("桌面操作互斥與取消結果防護", OperationOwnershipAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), ("連線 URI 安全匯入", ConnectionUriImportAsync), ("TLS 憑證檔案安全套用", TlsCertificateFilesAsync), @@ -47,6 +48,33 @@ static Task BatchInsertAsync() return Task.CompletedTask; } +static async Task OperationOwnershipAsync() +{ + var gate = new OperationGate(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var published = false; + var running = Task.Run(async () => + { + using var operation = gate.TryEnter(); + Assert(operation is not null, "First operation admitted"); + entered.SetResult(); + await release.Task; + cancellation.Token.ThrowIfCancellationRequested(); + published = true; + }); + await entered.Task; + Assert(gate.TryEnter() is null, "Duplicate SQL rejected"); + cancellation.Cancel(); + Assert(gate.TryEnter() is null, "Cancellation does not release gate before provider returns"); + release.SetResult(); + try { await running; throw new Exception("Cancellation ignored"); } catch (OperationCanceledException) { } + Assert(!published, "Cancelled provider result not published"); + using var next = gate.TryEnter(); + Assert(next is not null, "Gate reusable after completion"); +} + static async Task SessionOwnershipAsync() { using var cancellation = new CancellationTokenSource(); diff --git a/mySQLPunk.Desktop/MainWindow.axaml.cs b/mySQLPunk.Desktop/MainWindow.axaml.cs index f2a130e5..f7f31d9f 100644 --- a/mySQLPunk.Desktop/MainWindow.axaml.cs +++ b/mySQLPunk.Desktop/MainWindow.axaml.cs @@ -30,6 +30,7 @@ public sealed partial class MainWindow : Window private IReadOnlyList _databaseObjects = Array.Empty(); private QueryResult? _lastResult; private CancellationTokenSource? _operationCancellation; + private readonly OperationGate _operationGate = new(); private bool _loadingDatabases; private readonly string? _initialSqlDocumentPath; private string? _pendingActivatedSqlPath; @@ -916,6 +917,7 @@ private async void SqlEditor_KeyDown(object? sender, KeyEventArgs e) if (e.Key is Key.Enter or Key.Return) { e.Handled = true; + if (_operationCancellation is not null) return; await ExecuteCurrentSqlAsync( executeDocument: e.KeyModifiers.HasFlag(KeyModifiers.Shift)); } @@ -1152,6 +1154,7 @@ private void OpenReleasePage(CrossPlatformUpdateInfo update) private async Task ExecuteCurrentSqlAsync(bool executeDocument = false) { + if (_operationCancellation is not null) return; if (_session is null || _databaseCombo.SelectedItem is not string database) { await MessageDialog.ShowAsync(this, "尚未連線", "請先選擇連線設定並連線。", showCancel: false); @@ -1185,6 +1188,7 @@ await MessageDialog.ShowAsync( await RunOperationAsync(operationStatus, async cancellationToken => { var result = await _session.ExecuteAsync(database, execution.Sql, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); stopwatch.Stop(); DisplayResult(result); var historyRecorded = _queryHistory.Add(new QueryExecutionHistoryEntry( @@ -1231,6 +1235,7 @@ private async Task LoadObjectsAsync(string database, CancellationToken cancellat } var objects = await _session.GetObjectsAsync(database, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); _databaseObjects = objects; var filteredCount = ApplyObjectFilter(); SetStatus($"{database}:已載入 {objects.Count} 個物件,目前顯示 {filteredCount} 個。"); @@ -1470,8 +1475,8 @@ await RunOperationAsync("正在匯出查詢結果…", async cancellationToken = private async Task RunOperationAsync(string status, Func operation) { - _operationCancellation?.Cancel(); - _operationCancellation?.Dispose(); + using var operationLease = _operationGate.TryEnter(); + if (operationLease is null) return; var cancellation = new CancellationTokenSource(); _operationCancellation = cancellation; SetBusy(true, status); From 0ca4517a0ad145f7659cb8fb4c05e50536919679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:33:33 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E5=B0=87=E5=82=B3=E8=BC=B8=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E5=A4=B1=E6=95=97=E8=88=87=E4=B8=8D=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E6=A8=99=E7=82=BA=E4=B8=8D=E5=8F=AF=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E9=87=8D=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列數不符不能仍標為 Done。新增驗證失敗狀態,只有明確驗證通過才算完成;每批寫入前保存不可重試 checkpoint,成功記錄進度後解除。沒有主鍵的續傳不再刪除既有目標重來,保留資料供人工核對。 回歸測試涵蓋額外目標列、驗證失敗重跑不寫入與不確定 checkpoint。隔離 mock 執行實際 DataTransferService,確認部分寫入錯誤、舊 Done 狀態與無主鍵續傳不會誤報完成或重複寫入。 --- CHANGELOG.md | 1 + mySQLPunk/lib/DataTransferService.cs | 30 ++++++++++++++++------------ tests/SmokeTests.SchemaSync.cs | 16 +++++++++++++++ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a816a8c..6a44c329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - SQL Server 批次新增依 2,100 參數與 1,000 VALUES 列上限切批,整次呼叫共用交易,中途失敗不留下前批資料。 - 桌面連線測試、取得資料庫或取消失敗時釋放尚未移交的 session,避免 SSH tunnel 洩漏。 - 桌面 SQL 快捷鍵與操作共用互斥 gate,取消後等待舊操作結束才接受新操作,取消結果不再覆蓋畫面。 +- 資料傳輸列數不符明確標為驗證失敗,不視為完成;不確定批次及無主鍵續傳禁止自動重試,保留目標供核對。 - Windows 資料庫報表精靈的資料表清單顯示並可搜尋註解,PDF 標準目錄同步附上表註解;Punky 移到確認頁並放大,開始匯出自動循環、首輪播放原音效,提供靜音。樣板放大預覽改用高解析圖片,支援符合寬度、捲動、縮放;返回修改選項時會清除過期的完成狀態。 - Windows 資料同步寫入 PostgreSQL `SERIAL` 主鍵的明確值後不會推進序列,之後以預設值新增資料列可能撞主鍵;現在 identity 與 `DEFAULT nextval` 欄位寫入明確值後都會把序列移到最大值之後。 diff --git a/mySQLPunk/lib/DataTransferService.cs b/mySQLPunk/lib/DataTransferService.cs index 1152c288..fa6e05cf 100644 --- a/mySQLPunk/lib/DataTransferService.cs +++ b/mySQLPunk/lib/DataTransferService.cs @@ -30,7 +30,8 @@ public enum TransferItemStatus Pending, Running, Done, - Failed + Failed, + VerificationFailed } public sealed class TransferColumnMapping @@ -58,6 +59,7 @@ public TransferItem() [JsonConverter(typeof(StringEnumConverter))] public TransferItemStatus Status { get; set; } public long CopiedRows { get; set; } + public bool RetryBlocked { get; set; } public long SourceRows { get; set; } public long TargetRowsBefore { get; set; } public long TargetRowsAfter { get; set; } @@ -93,7 +95,7 @@ public TransferPlan() [JsonIgnore] public bool IsFinished { - get { return Items.Where(item => item.Include).All(item => item.Status == TransferItemStatus.Done); } + get { return Items.Where(item => item.Include).All(item => item.Status == TransferItemStatus.Done && item.Verified == true); } } public string RouteKey() @@ -175,7 +177,7 @@ public static void Run( Action checkpoint, CancellationToken cancellationToken) { - List items = plan.Items.Where(item => item.Include && item.Status != TransferItemStatus.Done).ToList(); + List items = plan.Items.Where(item => item.Include && (item.Status != TransferItemStatus.Done || item.Verified != true)).ToList(); int index = 0; foreach (TransferItem item in items) { @@ -195,7 +197,7 @@ public static void Run( } catch (Exception exception) { - item.Status = TransferItemStatus.Failed; + if (item.Status != TransferItemStatus.VerificationFailed) item.Status = TransferItemStatus.Failed; item.Error = ExceptionMessageService.GetReason(exception); item.Seconds = previousSeconds + watch.Elapsed.TotalSeconds; Save(plan, checkpoint); @@ -220,21 +222,16 @@ private static void RunItem( CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(item.TargetTable)) throw new InvalidOperationException(Localization.Format("Transfer.Error.TargetName", item.SourceTable)); + if (item.RetryBlocked || item.Verified == false || item.Status == TransferItemStatus.VerificationFailed) + throw new InvalidOperationException("傳輸結果尚未確認,禁止自動重試。請核對來源與目標內容,另選新的空目標重新建立計畫。"); item.Error = null; item.Warning = null; item.Verified = null; bool resuming = item.CopiedRows > 0; if (resuming && !resumable) { - // 沒有主鍵時分頁順序不穩定,無法確定哪些列已寫入:新表或取代模式從頭重來,附加模式拒絕以免重複。 - if (item.Mode == TransferMode.Append) throw new InvalidOperationException(Localization.Format("Transfer.Error.CannotResumeAppend", item.SourceTable)); - if (item.Mode == TransferMode.CreateNew && SafeTableExists(target, plan.TargetDatabase, item.TargetTable)) - { - target.DropTableForCopy(plan.TargetDatabase, item.TargetTable); - } - item.CopiedRows = 0; - resuming = false; - item.Warning = Localization.T("Transfer.Warn.Restarted"); + item.RetryBlocked = true; + throw new InvalidOperationException("沒有主鍵的傳輸無法安全續傳。保留現有目標,請另選新的空目標重新建立計畫。"); } item.Status = TransferItemStatus.Running; @@ -292,8 +289,11 @@ private static void RunItem( DataTable page = source.SelectTablePage(plan.SourceDatabase, item.SourceTable, item.CopiedRows, batch); DataSyncService.ThrowIfQueryFailed(page); if (page.Rows.Count == 0) break; + item.RetryBlocked = true; + Save(plan, checkpoint); target.InsertTableBatch(plan.TargetDatabase, item.TargetTable, mapping == null ? page : Remap(page, mapping)); item.CopiedRows += page.Rows.Count; + item.RetryBlocked = false; Save(plan, checkpoint); if (progress != null) { @@ -321,6 +321,9 @@ private static void RunItem( { item.Warning = (item.Warning == null ? string.Empty : item.Warning + " ") + Localization.Format("Transfer.Warn.CountMismatch", item.SourceRows, item.CopiedRows, item.TargetRowsAfter, expected); + item.RetryBlocked = true; + item.Status = TransferItemStatus.VerificationFailed; + throw new InvalidOperationException(item.Warning); } item.Status = TransferItemStatus.Done; } @@ -495,6 +498,7 @@ public static string ResultText(TransferItem item) case TransferItemStatus.Done: return item.Verified == true ? Localization.T("Transfer.Result.Verified") : Localization.T("Transfer.Result.Mismatch"); case TransferItemStatus.Failed: + case TransferItemStatus.VerificationFailed: return Localization.T("Transfer.Result.Failed"); case TransferItemStatus.Running: return Localization.Format("Transfer.Result.Partial", item.CopiedRows); diff --git a/tests/SmokeTests.SchemaSync.cs b/tests/SmokeTests.SchemaSync.cs index 910a28fe..52e352a9 100644 --- a/tests/SmokeTests.SchemaSync.cs +++ b/tests/SmokeTests.SchemaSync.cs @@ -546,6 +546,22 @@ public static void AssertDataTransferSemantics(IDatabase source, IDatabase targe AssertEquals("7", target.CountRows("main", "logs").ToString(), "Create new should copy every row."); AssertEquals("5", target.CountRows("main", "items").ToString(), "Replace data should leave exactly the source rows."); Assert(DataTransferService.LoadUnfinished(checkpointDirectory, plan) == null, "A finished transfer is no longer offered for resume."); + TransferPlan mismatch = DataTransferService.BuildPlan(source, "main", "Source", target, "main", "Mismatch", new[] { "customers" }); + mismatch.Items[0].TargetTable = "mismatch_copy"; + bool changed = false; + DataTransferService.Run(mismatch, source, target, keyed, progress => + { + if (!changed) { changed = true; exec(target, "INSERT INTO mismatch_copy VALUES (999, 'extra', 'city')"); } + }, save, System.Threading.CancellationToken.None); + Assert(mismatch.Items[0].Status == TransferItemStatus.VerificationFailed && !mismatch.IsFinished, "Count mismatch must be an unfinished verification failure."); + long mismatchRows = target.CountRows("main", "mismatch_copy"); + DataTransferService.Run(mismatch, source, target, keyed, null, save, System.Threading.CancellationToken.None); + AssertEquals(mismatchRows.ToString(), target.CountRows("main", "mismatch_copy").ToString(), "Verification failure must not retry writes."); + TransferPlan uncertain = DataTransferService.BuildPlan(source, "main", "Source", target, "main", "Uncertain", new[] { "customers" }); + uncertain.Items[0].RetryBlocked = true; + uncertain.Items[0].TargetTable = "never_created"; + DataTransferService.Run(uncertain, source, target, keyed, null, save, System.Threading.CancellationToken.None); + Assert(uncertain.Items[0].Status == TransferItemStatus.Failed && !uncertain.IsFinished, "Uncertain batch checkpoint must block before creating a target."); string json = File.ReadAllText(Path.Combine(checkpointDirectory, plan.Id + ".json")); Assert(json.Contains("customers_archive") && json.IndexOf("pwd", StringComparison.OrdinalIgnoreCase) < 0, "Checkpoints only keep names, never secrets."); From 341d23d468467c0963f689e24417635254068c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:33:51 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=9C=A8=20SQL=20Server=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E9=82=84=E5=8E=9F=E5=89=8D=E6=AA=A2=E6=9F=A5=E5=82=99?= =?UTF-8?q?=E4=BB=BD=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新版 .bak 不能還原到舊引擎,compatibility level 也不會改變備份格式。還原先讀 HEADERONLY 與目標 ProductVersion,版本未知、來源較新、複數備份組或非完整備份都在改動資料庫前拒絕,不再等到 RESTORE 才失敗。 離線測試覆蓋同版本、降版拒絕、未知版本、複數備份與非完整備份。Microsoft RESTORE Compatibility support 已核對;實際引擎還原測試未執行。 --- CHANGELOG.md | 1 + mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 7 ++++++ .../mySQLPunk.CrossPlatform.SmokeTests.csproj | 2 ++ mySQLPunk/lib/NativeBackupService.cs | 6 +++++ .../lib/SqlServerRestoreCompatibility.cs | 21 ++++++++++++++++++ mySQLPunk/mySQLPunk.csproj | 1 + tests/Run-SmokeTests.ps1 | 1 + tests/SmokeTests.cs | 1 + tests/SqlServerRestoreCompatibilityTests.cs | 22 +++++++++++++++++++ 9 files changed, 62 insertions(+) create mode 100644 mySQLPunk/lib/SqlServerRestoreCompatibility.cs create mode 100644 tests/SqlServerRestoreCompatibilityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a44c329..15e0a676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ - 桌面連線測試、取得資料庫或取消失敗時釋放尚未移交的 session,避免 SSH tunnel 洩漏。 - 桌面 SQL 快捷鍵與操作共用互斥 gate,取消後等待舊操作結束才接受新操作,取消結果不再覆蓋畫面。 - 資料傳輸列數不符明確標為驗證失敗,不視為完成;不確定批次及無主鍵續傳禁止自動重試,保留目標供核對。 +- SQL Server 原生還原先確認備份與目標引擎版本,拒絕新版備份還原至舊引擎,以及未知、複數或非完整備份。 - Windows 資料庫報表精靈的資料表清單顯示並可搜尋註解,PDF 標準目錄同步附上表註解;Punky 移到確認頁並放大,開始匯出自動循環、首輪播放原音效,提供靜音。樣板放大預覽改用高解析圖片,支援符合寬度、捲動、縮放;返回修改選項時會清除過期的完成狀態。 - Windows 資料同步寫入 PostgreSQL `SERIAL` 主鍵的明確值後不會推進序列,之後以預設值新增資料列可能撞主鍵;現在 identity 與 `DEFAULT nextval` 欄位寫入明確值後都會把序列移到最大值之後。 diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 789f1467..52158f2a 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -12,6 +12,7 @@ var tests = new List<(string Name, Func Run)> { ("SQL Server 分批交易與重試", BatchInsertAsync), + ("SQL Server 原生還原版本預檢", RestoreCompatibilityAsync), ("桌面 session 失敗與取消所有權", SessionOwnershipAsync), ("桌面操作互斥與取消結果防護", OperationOwnershipAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), @@ -48,6 +49,12 @@ static Task BatchInsertAsync() return Task.CompletedTask; } +static Task RestoreCompatibilityAsync() +{ + SqlServerRestoreCompatibilityTests.Run(); + return Task.CompletedTask; +} + static async Task OperationOwnershipAsync() { var gate = new OperationGate(); diff --git a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj index e7ccb9d9..710c309d 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj +++ b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj @@ -12,6 +12,8 @@ + + diff --git a/mySQLPunk/lib/NativeBackupService.cs b/mySQLPunk/lib/NativeBackupService.cs index 102240c7..7f4ff39f 100644 --- a/mySQLPunk/lib/NativeBackupService.cs +++ b/mySQLPunk/lib/NativeBackupService.cs @@ -125,6 +125,12 @@ public static NativeBackupResult RestoreSqlServer(IDatabase database, string ser try { ValidateNewDatabaseName(newDatabase); + DataTable headers = database.SelectSQL("RESTORE HEADERONLY FROM DISK = N'" + serverPath.Replace("'", "''") + "'"); + DataSyncService.ThrowIfQueryFailed(headers); + DataTable version = database.SelectSQL("SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version"); + DataSyncService.ThrowIfQueryFailed(version); + if (version.Rows.Count != 1) throw new InvalidOperationException("無法確認 SQL Server 引擎版本。"); + SqlServerRestoreCompatibility.Validate(headers, Convert.ToString(version.Rows[0][0], CultureInfo.InvariantCulture)); DataTable exists = database.SelectSQL("SELECT DB_ID(@name) AS id", new Dictionary { { "name", newDatabase } }); DataSyncService.ThrowIfQueryFailed(exists); if (exists.Rows.Count > 0 && !(exists.Rows[0][0] is DBNull)) throw new InvalidOperationException(Localization.Format("NativeBackup.Error.DatabaseExists", newDatabase)); diff --git a/mySQLPunk/lib/SqlServerRestoreCompatibility.cs b/mySQLPunk/lib/SqlServerRestoreCompatibility.cs new file mode 100644 index 00000000..94a297b5 --- /dev/null +++ b/mySQLPunk/lib/SqlServerRestoreCompatibility.cs @@ -0,0 +1,21 @@ +using System; +using System.Data; +using System.Globalization; + +namespace mySQLPunk.lib +{ + public static class SqlServerRestoreCompatibility + { + public static void Validate(DataTable headers, string targetVersion) + { + if (!Version.TryParse(targetVersion, out var target) || headers == null || headers.Rows.Count != 1 || + !headers.Columns.Contains("SoftwareVersionMajor") || !headers.Columns.Contains("BackupType")) + throw new InvalidOperationException("無法確認備份或目標引擎版本,已停止還原。請選擇單一完整備份並改用受控邏輯搬移。"); + int source = Convert.ToInt32(headers.Rows[0]["SoftwareVersionMajor"], CultureInfo.InvariantCulture); + if (source < 8 || source > target.Major) + throw new InvalidOperationException("新版 SQL Server 的 .bak 不能還原到舊引擎;compatibility level 不會改變備份格式。請改用相容搬移預檢。"); + if (Convert.ToInt32(headers.Rows[0]["BackupType"], CultureInfo.InvariantCulture) != 1) + throw new InvalidOperationException("此介面僅支援單一完整資料庫備份;差異或交易記錄備份需由 DBA 規劃還原鏈。"); + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index e9301ce0..7caa6901 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -449,6 +449,7 @@ + diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index f936873a..a06178e9 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -144,6 +144,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` (Join-Path $PSScriptRoot "SqlServerBatchInsertTests.cs") ` + (Join-Path $PSScriptRoot "SqlServerRestoreCompatibilityTests.cs") ` (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.TransferTrial.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Live.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Wizard.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Parser.cs") $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Snapshot.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.ViewReferences.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Properties.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") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Routing.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.SnapshotIntegration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.CompatibilityIntegration.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 73b2f108..6c54f72c 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -13219,6 +13219,7 @@ private static void TestDataTransfer() batchConnection.Open(); SqlServerBatchInsertTests.Run(batchConnection); } + SqlServerRestoreCompatibilityTests.Run(); string dir = Path.Combine(Path.GetTempPath(), "mysqlpunk-transfer-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try diff --git a/tests/SqlServerRestoreCompatibilityTests.cs b/tests/SqlServerRestoreCompatibilityTests.cs new file mode 100644 index 00000000..d8d4a77e --- /dev/null +++ b/tests/SqlServerRestoreCompatibilityTests.cs @@ -0,0 +1,22 @@ +using System; +using System.Data; +using mySQLPunk.lib; + +public static class SqlServerRestoreCompatibilityTests +{ + public static void Run() + { + var headers = new DataTable(); headers.Columns.Add("SoftwareVersionMajor", typeof(int)); headers.Columns.Add("BackupType", typeof(int)); headers.Rows.Add(16, 1); + Reject(() => SqlServerRestoreCompatibility.Validate(headers, "15.0.0.0")); + SqlServerRestoreCompatibility.Validate(headers, "16.0.0.0"); + Reject(() => SqlServerRestoreCompatibility.Validate(headers, "unknown")); + headers.Rows.Add(15, 1); Reject(() => SqlServerRestoreCompatibility.Validate(headers, "16.0.0.0")); headers.Rows.RemoveAt(1); + headers.Rows[0]["BackupType"] = 2; Reject(() => SqlServerRestoreCompatibility.Validate(headers, "16.0.0.0")); + } + + private static void Reject(Action action) + { + try { action(); } catch (InvalidOperationException) { return; } + throw new Exception("Unsafe restore accepted"); + } +} From b3a7c7f1d9bd5382985e5af6f94634f9d106bb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 10:36:56 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8F=AA?= =?UTF-8?q?=E5=AF=AB=E5=85=A5=E7=A9=BA=E7=9B=AE=E6=A8=99=E7=9A=84=20SQL=20?= =?UTF-8?q?Server=20=E9=99=8D=E7=89=88=E7=9B=B8=E5=AE=B9=E6=90=AC=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增來源與目標引擎識別、明確版本選擇、結構與列數預覽,以及輸入目標名稱後才執行的確認流程。交易內再次核對預檢指紋,只搬移 dbo 一般 heap 與白名單型別,identity、constraints、indexes、triggers、procs、schema 相依、定序差異及進階功能一律阻擋,不自動覆蓋或略過。 資料以 500 列分批寫入單一交易,逐欄型別、NULL、內容雜湊與結構全部相符才提交。失敗或取消回滾;提交回覆遺失、回滾失敗或清理結果不確定時禁止重試,報告保留階段及例外類型而不附資料或連線秘密。 離線跨平台 smoke 29/29,隔離 SQLite UI 連線、快捷鍵 SQL 與結果網格通過;實際 DataTransferService mock 回歸、搬移 mock 與 Mono 表單相依編譯也通過。2016–2025 尚未建立真 SQL Server 降版矩陣,文件與 UI 明確標示預覽版與限制,Windows 完整 build/smoke 交由 PR CI 驗證。 --- CHANGELOG.md | 1 + README.md | 1 + docs/FEATURE_NOTES.md | 3 + docs/MSSQL_COMPATIBILITY_MIGRATION.md | 56 +++++ mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 7 + .../mySQLPunk.CrossPlatform.SmokeTests.csproj | 2 + mySQLPunk/Form1.cs | 19 ++ mySQLPunk/SqlServerMigrationForm.cs | 107 +++++++++ .../lib/SqlServerCompatibilityMigration.cs | 214 ++++++++++++++++++ mySQLPunk/lib/SqlServerMigrationSession.cs | 160 +++++++++++++ mySQLPunk/mySQLPunk.csproj | 3 + tests/Run-SmokeTests.ps1 | 1 + tests/SmokeTests.cs | 1 + tests/SqlServerMigrationTests.cs | 119 ++++++++++ 14 files changed, 694 insertions(+) create mode 100644 docs/MSSQL_COMPATIBILITY_MIGRATION.md create mode 100644 mySQLPunk/SqlServerMigrationForm.cs create mode 100644 mySQLPunk/lib/SqlServerCompatibilityMigration.cs create mode 100644 mySQLPunk/lib/SqlServerMigrationSession.cs create mode 100644 tests/SqlServerMigrationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e0a676..ed16dd34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🚀 新增功能 +- **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 尚僅離線/mock 驗證,不宣稱真引擎矩陣通過。 - **系統資料庫規格書樣版(Windows,待最終匯出驗收)**:DOCX 新增第三種樣版,提供 A4 直式資料表清單、主鍵與索引摘要、淡藍色可編輯欄位表,以及 PK/NN 和長度/精度欄;中文名稱沿用資料庫註解。 - **Windows 建置與啟動腳本**:根目錄新增 `build.bat` 與 `run.bat`,支援 `debug` / `release` 且預設 Debug;自動尋找 MSBuild 並還原套件,`run.bat` 只在編譯成功後啟動對應版本。 diff --git a/README.md b/README.md index 87bccccc..3375d47f 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ SQLite / PostgreSQL / SQL Server database rename 實機矩陣(需先啟動 Doc | Table/View 複製 | 可用 | 跨 provider 複製 Table/View;View SQL 無法安全轉換時會改用 table snapshot。 | | SQL Dump | 可用 | 支援多 provider Table dump;SQLite 欄位註解 sidecar metadata 會隨結構匯出;各 provider 的 DDL 細節仍會依 metadata 能力不同而有差異。 | | 匯出 / Dump / Backup service | 可用 | 查詢結果多格式匯出、SQL dump 與邏輯 SQL 備份已抽出 service,Form UI 只負責觸發、檔案對話框與狀態呈現。 | +| SQL Server 降版相容搬移 | 有限範圍預覽版(Windows) | 明確選版本、預檢與確認後,只搬移一般 dbo heap 到新的空目標;分批交易與內容驗證。identity/索引/constraints/routines 等拒絕;2016–2025 尚無真引擎矩陣驗證,詳見 [範圍與矩陣](docs/MSSQL_COMPATIBILITY_MIGRATION.md)。 | | 自動執行作業 | 可用 | 「工具 > 自動執行作業」可建立唯讀查詢、查詢結果匯出與 SQL 備份;支援立即執行、每日 Windows 工作排程與 JSON 執行紀錄。排程使用同一個已登入的 Windows 帳號讀取 Credential Manager,不把帳密寫進作業檔。詳見 [`docs/AUTOMATION.md`](docs/AUTOMATION.md)。 | | 連線與 metadata service | 可用 | 連線開啟、retry 判斷與 database metadata snapshot 已抽出 service,Form UI 保留 TreeView 呈現與錯誤提示。 | | 選項中心 | 部分可用 | 已補齊主要分類頁與 `application-options.json` 保存;查詢視窗已套用記錄限制、編輯器字型/換行/Tab 空格、自動完成開關、大型 SQL 停用編輯器輔助、資料表儲存自動交易、SQL 檔案位置、匯出位置、還原差異抽樣列數、結果網格字型與列高度、日期/時間與數字格式、工具提示顯示開關、診斷記錄、自動復原草稿、索引標籤開啟偏好、HTTP 代理與進階註冊設定。 | diff --git a/docs/FEATURE_NOTES.md b/docs/FEATURE_NOTES.md index 8c9dffb3..498fec5e 100644 --- a/docs/FEATURE_NOTES.md +++ b/docs/FEATURE_NOTES.md @@ -615,3 +615,6 @@ - 已知情境:Oracle 階層查詢、MySQL 專用 View 語法、帶 OFFSET 且缺少穩定排序的 SQL Server 轉換、無法解析的 SELECT SQL 仍會改用 table snapshot。 - 測試覆蓋:`tests/SmokeTests.cs` 已加入 TOP / LIMIT / LIMIT OFFSET / OFFSET FETCH / FETCH FIRST / ROWNUM、日期格式與解析(含 SQL Server `CONVERT` style 23/120、`TO_DATE`、`TO_TIMESTAMP`、`STR_TO_DATE`)、目前日期/時間(含 `GETUTCDATE()` / `UTC_TIMESTAMP()` / `SYSDATE` / `SYSTIMESTAMP`)、`DATE_TRUNC` 與 Oracle `TRUNC(..., 'MM'/'YYYY'/'HH24'/'MI')` 日期截斷、`NVL2` 空值條件、`DATE` / `TRUNC` 日期截斷、`DATEDIFF` / `TIMESTAMPDIFF` / `MONTHS_BETWEEN` 年/月/日/時分秒差、`DATEADD` / `DATE_ADD` / `DATE_SUB` / `ADD_MONTHS` 年/月/日/時加減、`EOMONTH` / `LAST_DAY` 月末日期、`DATEFROMPARTS` 日期組裝與 `YEAR` / `MONTH` / `DAY` / `HOUR` / `MINUTE` / `SECOND` / `DATEPART` / `DATE_PART` / `EXTRACT` 日期部分函式、`IF` / `IIF` / `DECODE` 條件函式、`CEIL` / `CEILING` 數值進位、`MOD` 取餘數、`POW` / `POWER` 次方、`GREATEST` / `LEAST` 最大/最小值比較、`TRUE` / `FALSE` 布林常值、PostgreSQL `欄位::型別` cast operator、SQL Server `TRY_CAST` / `TRY_CONVERT`、`NULLS FIRST/LAST` 空值排序、`RAND` / `RANDOM` / `DBMS_RANDOM.VALUE` 隨機數、PostgreSQL `ILIKE`、`REGEXP_LIKE` / PostgreSQL `~` 正規表示式比對、字串聚合、`CONCAT` 與 `||` 字串串接、`LEN` / `LENGTH` / `CHAR_LENGTH` / `CHARACTER_LENGTH` 字串長度、`TRIM` / `LTRIM` / `RTRIM` 字串修剪、`SUBSTR` / `SUBSTRING` / `SUBSTRING ... FROM ... FOR` / `LEFT` / `RIGHT` / `LPAD` / `RPAD` 字串擷取與補齊、`LOCATE` / `CHARINDEX` / `INSTR` / `POSITION` 字串位置、JSON 純量讀取(含 `JSON_EXTRACT` / `JSON_UNQUOTE(JSON_EXTRACT(...))` / `JSON_VALUE` / MySQL `->>` / PostgreSQL `->>` / `#>>` 轉 PostgreSQL / MySQL / Oracle / SQL Server)、JSON 片段讀取(含 `JSON_QUERY` / MySQL `->` / PostgreSQL `->` / `#>` 轉 MySQL / PostgreSQL / SQLite)、JSON path 存在判斷(含 `JSON_EXISTS` / `JSON_CONTAINS_PATH` 轉 MySQL / PostgreSQL / SQLite / SQL Server / Oracle)、JSON 陣列長度(含 `JSON_LENGTH` / `JSON_ARRAY_LENGTH` 轉 MySQL / PostgreSQL / SQLite / SQL Server / Oracle)、JSON 物件/陣列建構(含 MySQL、PostgreSQL、SQLite、Oracle 互轉)、簡單 `JSON_TABLE` 轉 PostgreSQL / SQL Server / SQLite、`JSON_TABLE FOR ORDINALITY` 轉 PostgreSQL / SQLite 與 SQL Server 不支援原因、CTE/window 保留、`WITH RECURSIVE` 關鍵字轉換與不支援轉換原因的可重跑案例。 - 後續方向:若需要更高相容性,可再逐 provider 擴充 JSON table 與更多 provider 專用內建函式等更複雜 SQL 方言轉換規則。 +## SQL Server 搬移安全性 + +新增受限的 SQL Server 邏輯降版預檢與 Windows 確認介面;來源/目標版本、空目標、定序、feature 白名單、全交易及內容驗證的詳細範圍與未驗證矩陣,見 [MSSQL_COMPATIBILITY_MIGRATION.md](MSSQL_COMPATIBILITY_MIGRATION.md)。原生 RESTORE 的版本 guard 不等於完整邏輯降版;一般資料傳輸的列數驗證也不等於內容驗證。 diff --git a/docs/MSSQL_COMPATIBILITY_MIGRATION.md b/docs/MSSQL_COMPATIBILITY_MIGRATION.md new file mode 100644 index 00000000..2a0073a2 --- /dev/null +++ b/docs/MSSQL_COMPATIBILITY_MIGRATION.md @@ -0,0 +1,56 @@ +# SQL Server 降版相容搬移(有限範圍預覽版) + +這是 Windows 資料庫右鍵的獨立邏輯搬移工具,不是完整資料庫降版器。一般資料傳輸仍只驗證列數,不能當成降版驗證。 + +## 引擎矩陣與驗證狀態 + +| 來源引擎 | 可選舊版目標 | 狀態 | +| --- | --- | --- | +| 2025 (17) | 2022 (16)、2019 (15)、2017 (14)、2016 (13) | 預檢與交易流程只有 mock 驗證 | +| 2022 (16) | 2019 (15)、2017 (14)、2016 (13) | 同一受限實作;沒有真引擎矩陣證據 | +| 2019 (15) | 2017 (14)、2016 (13) | 同上 | +| 2017 (14) | 2016 (13) | 同上 | +| 2016 (13) 或更早/未知 | 無 | 拒絕;不猜測最低版本 | + +UI 不預選引擎,必須明確選取目標,並與 SERVERPROPERTY 實際版本一致。這張表代表程式允許預檢的範圍,不代表各版本已通過真 SQL Server 驗證,也不宣稱生產支援。Linux/macOS 目前只有共用核心 mock 測試,尚無此搬移 UI。 + +## 操作與邊界 + +1. 先由使用者另建空目標資料庫,再從目前已展開的 SQL Server 連線選取來源與目標;工具不建立資料庫、不自動清空或覆蓋。 +2. 唯讀預檢顯示伺服器/資料庫識別、引擎 major、定序、表名與列數、CREATE TABLE 語句及阻擋原因,不顯示資料值。 +3. 來源與目標不得為同一資料庫;定序必須完全相同。只使用連線既有的 CONTROL 與 VIEW DEFINITION 權限,缺少即停止,不變更權限。 +4. 輸入完整目標名稱並按確認後才開啟交易。交易內重做預檢;識別、名稱、版本、結構、列數或空目標改變就拒絕。 +5. 每批最多 500 列;INSERT 再依 2,100 參數及 1,000 VALUES 列限制切批。所有建立與寫入使用同一個目標交易。 +6. 來源以 SERIALIZABLE 讀取,目標以 SERIALIZABLE 加工具專用 applock;可能阻擋其他寫入。請使用隔離副本或安排維護時段,這不是線上無停機搬移。 +7. 逐列以型別、NULL、值的 SHA-256 多重集合核對內容與列數,並重新檢查來源及目標結構。全部通過才提交。 +8. 每次最多 100,000 列,單一欄位編碼最多 1 MiB;不是大型資料庫 bulk migration。失敗/取消回滾,不自動重試。提交回覆遺失、回滾或清理失敗時,明確回報結果不確定,必須由 DBA 核對再決定下一步。 + +獨立 SQL 連線沿用目前連線設定。若驅動程式已移除 SQL authentication 密碼,獨立連線可能無法建立;工具不讀取密碼檔或秘密庫來繞過,請以可建立獨立連線的正常認證方式重新連線。 + +## 支援與 fail closed + +只搬移 dbo、PRIMARY filegroup、未壓縮的一般 heap 的欄位與資料。保留名稱、型別長度/精度/scale、NULL。明確阻擋: + +- identity、computed、default/check/PK/FK/unique constraints、所有索引(包含停用索引)、triggers、procedures、functions、views、sequences。 +- 自訂 schema/型別、CLR、分割、非 PRIMARY 儲存、extended properties、change tracking、CDC、replication、RLS、temporal、memory optimized、FileTable、graph、ledger。 +- sparse、column set、rowguid、FILESTREAM、hidden、masked、generated、encrypted、ANSI_PADDING OFF 欄位,以及不同於資料庫定序的欄位。 +- max 長度、xml、float/real、datetimeoffset、rowversion、sql_variant、spatial、vector 等不在白名單的型別;不能確認的 catalog 欄位或查詢錯誤也停止。 + +型別白名單:bigint/int/smallint/tinyint、bit、date/datetime/smalldatetime、datetime2(0–7)、time(0–7)、money/smallmoney、uniqueidentifier、decimal/numeric(precision 1–38,scale 不大於 precision)、char/varchar/binary/varbinary(1–8000)、nchar/nvarchar(1–4000)。decimal 超出 .NET decimal 可表示範圍時讀取失敗並回滾,不截斷。 + +不複製資料庫/伺服器設定、登入、使用者、角色、授權、作業或應用程式相依。非白名單 feature 不會被略過後假報成功。錯誤報告包含階段、表名與例外類型,不包含資料值、連線字串或驅動程式原始錯誤內容。 + +## 備份與替代路徑 + +Microsoft 的 RESTORE Compatibility support 明定新版備份不能還原到舊版引擎;降低 compatibility level 並不重寫備份格式。原生還原現在先讀 HEADERONLY 與目標 ProductVersion,未知 metadata、複數 backup sets、非完整備份或來源較新一律在寫入前拒絕。 + +不在本工具範圍的資料庫,請由 DBA 在隔離環境產生針對精確目標版本的邏輯結構腳本,審核型別、定序、identity、constraints/indexes/triggers/routines 與相依順序,再搬資料並做內容、結構和應用程式測試。bacpac 有自身的模型與目標相容限制,不能視為無限制降版方案。 + +官方依據: +- https://learn.microsoft.com/en-us/sql/t-sql/statements/restore-statements-transact-sql?view=sql-server-ver17#compatibility-support +- https://learn.microsoft.com/en-us/sql/sql-server/maximum-capacity-specifications-for-sql-server?view=sql-server-ver17 +- https://learn.microsoft.com/en-us/sql/t-sql/queries/table-value-constructor-transact-sql?view=sql-server-ver17 + +## 驗證 + +離線 SQLite 測試覆蓋 INSERT 邊界、中途失敗整批回滾與重試不重複;mock 覆蓋版本、確認、空目標、定序、feature 阻擋、內容/結構不符、取消、Begin/寫入/提交/回滾失敗。Windows UI 編譯與基本 smoke 交由 Windows CI;尚未以真 SQL Server 2016–2025 引擎或人工 Windows 點選驗證。 diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 52158f2a..e74bbdad 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -13,6 +13,7 @@ { ("SQL Server 分批交易與重試", BatchInsertAsync), ("SQL Server 原生還原版本預檢", RestoreCompatibilityAsync), + ("SQL Server 降版預檢與交易 mock", MigrationAsync), ("桌面 session 失敗與取消所有權", SessionOwnershipAsync), ("桌面操作互斥與取消結果防護", OperationOwnershipAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), @@ -55,6 +56,12 @@ static Task RestoreCompatibilityAsync() return Task.CompletedTask; } +static Task MigrationAsync() +{ + SqlServerMigrationTests.Run(); + return Task.CompletedTask; +} + static async Task OperationOwnershipAsync() { var gate = new OperationGate(); diff --git a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj index 710c309d..650a6319 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj +++ b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj @@ -12,8 +12,10 @@ + + diff --git a/mySQLPunk/Form1.cs b/mySQLPunk/Form1.cs index 9bae1559..9430ef72 100644 --- a/mySQLPunk/Form1.cs +++ b/mySQLPunk/Form1.cs @@ -13035,6 +13035,13 @@ private void AddTableGroupMenuItems(ContextMenuStrip menu, TreeNode node) ToolStripMenuItem dataTransferItem = new ToolStripMenuItem(Localization.T("Tool.DataTransfer")); dataTransferItem.Click += (s, ev) => OpenDataTransfer(GetTargetFromCurrentSelection()); menu.Items.Add(dataTransferItem); + TreeDatabaseTarget migrationTarget = BuildTargetFromNode(node); + if (migrationTarget != null && migrationTarget.ProviderName == "mssql") + { + ToolStripMenuItem migrationItem = new ToolStripMenuItem("SQL Server 降版相容搬移..."); + migrationItem.Click += (s, ev) => OpenSqlServerMigration(migrationTarget); + menu.Items.Add(migrationItem); + } ToolStripMenuItem biDashboardItem = new ToolStripMenuItem(Localization.T("Tool.BiDashboard")); biDashboardItem.Click += (s, ev) => ShowBiDashboard(GetTargetFromCurrentSelection()); @@ -13156,6 +13163,18 @@ private void OpenDataTransfer(TreeDatabaseTarget sourceTarget) } } + private void OpenSqlServerMigration(TreeDatabaseTarget sourceTarget) + { + var source = BuildSchemaComparisonEndpoint(sourceTarget); + var targets = GetOpenSchemaComparisonEndpoints().Where(candidate => candidate.ProviderName == "mssql" && + !(ReferenceEquals(candidate.Database, source.Database) && candidate.DatabaseName == source.DatabaseName)).ToList(); + using (var dialog = new SchemaComparisonTargetDialog(source, targets, "選取另建的空 SQL Server 目標資料庫")) + { + if (dialog.ShowDialog(this) != DialogResult.OK || dialog.SelectedTarget == null) return; + using (var form = new SqlServerMigrationForm(source, dialog.SelectedTarget)) form.ShowDialog(this); + } + } + private void ShowQueryBuilder() { TreeDatabaseTarget target = GetTargetFromCurrentSelection(); diff --git a/mySQLPunk/SqlServerMigrationForm.cs b/mySQLPunk/SqlServerMigrationForm.cs new file mode 100644 index 00000000..ff0c54a9 --- /dev/null +++ b/mySQLPunk/SqlServerMigrationForm.cs @@ -0,0 +1,107 @@ +using System; +using System.Drawing; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using mySQLPunk.lib; + +namespace mySQLPunk +{ + public sealed class SqlServerMigrationForm : Form + { + private readonly SchemaComparisonEndpoint source; + private readonly SchemaComparisonEndpoint target; + private readonly ComboBox version = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 150 }; + private readonly TextBox preview = new TextBox { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, Dock = DockStyle.Fill }; + private readonly TextBox confirmation = new TextBox { Width = 180 }; + private readonly Button inspect = new Button { Text = "唯讀預檢", AutoSize = true }; + private readonly Button start = new Button { Text = "確認搬至空目標", AutoSize = true, Enabled = false }; + private readonly Button cancel = new Button { Text = "取消", Enabled = false }; + private SqlServerMigrationPreview approved; + private CancellationTokenSource cancellation; + private bool running; + + public SqlServerMigrationForm(SchemaComparisonEndpoint source, SchemaComparisonEndpoint target) + { + this.source = source; this.target = target; + Text = "SQL Server 降版相容搬移(有限範圍預覽版)"; + Width = 1000; Height = 680; StartPosition = FormStartPosition.CenterParent; + version.Items.AddRange(new object[] { "2016 (13)", "2017 (14)", "2019 (15)", "2022 (16)", "2025 (17)" }); + var controls = new FlowLayoutPanel { Dock = DockStyle.Bottom, Height = 76 }; + controls.Controls.Add(new Label { Text = "明確選取目標引擎", AutoSize = true }); controls.Controls.Add(version); + controls.Controls.Add(inspect); + controls.Controls.Add(new Label { Text = "輸入目標資料庫名稱", AutoSize = true }); controls.Controls.Add(confirmation); + controls.Controls.Add(start); controls.Controls.Add(cancel); + var notice = new Label { Dock = DockStyle.Top, Height = 100, Padding = new Padding(8), Text = + "來源:" + source.DisplayName + " → 目標:" + target.DisplayName + "\n" + + "只接受另建的空資料庫;不建立資料庫、不覆蓋、不自動重試。僅 dbo 一般 heap/白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能全部阻擋。定序必須相同。\n" + + "每次最多 100,000 列;分批寫入同一交易並逐欄內容驗證。2016–2025 目前僅 mock 驗證,尚無真實降版矩陣。來源使用 SERIALIZABLE,可能暫時阻擋其他寫入。" }; + Controls.Add(preview); Controls.Add(controls); Controls.Add(notice); + inspect.Click += Inspect; + start.Click += Start; + cancel.Click += (sender, args) => cancellation?.Cancel(); + version.SelectedIndexChanged += (sender, args) => { approved = null; start.Enabled = false; }; + FormClosing += (sender, args) => { if (running) { args.Cancel = true; cancellation?.Cancel(); } }; + } + + private SqlServerMigrationSession Open(SchemaComparisonEndpoint endpoint) + { + var database = endpoint.Database as my_mssql; + if (database == null) throw new InvalidOperationException("僅支援 SQL Server 來源與目標。"); + return new SqlServerMigrationSession(database.MCT, endpoint.DatabaseName); + } + + private async void Inspect(object sender, EventArgs args) + { + if (running || version.SelectedIndex < 0) return; + SetRunning(true); approved = null; + int major = SqlServerCompatibilityMigration.SupportedTargetMajors[version.SelectedIndex]; + try + { + var result = await Task.Run(() => + { + using (var from = Open(source)) using (var to = Open(target)) + return SqlServerCompatibilityMigration.Preview(from.ReadCatalog(), to.ReadCatalog(), major); + }); + approved = result; + preview.Text = "來源引擎 major " + result.Source.MajorVersion + ";目標 major " + result.Target.MajorVersion + Environment.NewLine + + "來源識別:" + result.Source.Identity + ";目標識別:" + result.Target.Identity + Environment.NewLine + + "定序:" + result.Source.Collation + " → " + result.Target.Collation + Environment.NewLine + + (result.CanStart ? "預檢通過;尚未寫入。" : "禁止執行:" + Environment.NewLine + string.Join(Environment.NewLine, result.Blockers)) + + Environment.NewLine + "資料預覽:500 列/批;保留 NULL 與原始型別;不顯示資料值。" + Environment.NewLine + + string.Join(Environment.NewLine, result.Source.Tables.Select(table => table.Name + ": " + table.RowCount + " 列")) + Environment.NewLine + + string.Join(Environment.NewLine, result.Statements); + } + catch { preview.Text = "無法完整讀取版本/metadata/權限;已停止。請確認連線與支援矩陣後重新預檢。"; } + finally { SetRunning(false); } + } + + private async void Start(object sender, EventArgs args) + { + if (running || approved == null || !approved.CanStart || confirmation.Text != target.DatabaseName) return; + if (MessageBox.Show(this, "確認只寫入「" + target.DatabaseName + "」?所有資料與結構驗證成功才提交;不支援的物件不會略過。", Text, + MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) return; + cancellation = new CancellationTokenSource(); SetRunning(true); + var plan = approved; string name = confirmation.Text; + try + { + var result = await Task.Run(() => + { + using (var from = Open(source)) using (var to = Open(target)) + return SqlServerCompatibilityMigration.Run(plan, name, from, to, cancellation.Token); + }); + preview.Text += Environment.NewLine + (result.Succeeded ? "搬移已提交。" : "搬移未成功。") + Environment.NewLine + result.Report; + } + catch { preview.Text += Environment.NewLine + "連線或清理發生錯誤,無法確認搬移結果;請由 DBA 核對目標,禁止直接重試。"; } + finally { cancellation.Dispose(); cancellation = null; approved = null; SetRunning(false); } + } + + private void SetRunning(bool value) + { + running = value; inspect.Enabled = !value; version.Enabled = !value; confirmation.Enabled = !value; + cancel.Enabled = value && cancellation != null; + start.Enabled = !value && approved != null && approved.CanStart; + } + } +} diff --git a/mySQLPunk/lib/SqlServerCompatibilityMigration.cs b/mySQLPunk/lib/SqlServerCompatibilityMigration.cs new file mode 100644 index 00000000..4150fa04 --- /dev/null +++ b/mySQLPunk/lib/SqlServerCompatibilityMigration.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace mySQLPunk.lib +{ + public sealed class SqlServerMigrationColumn + { + public string Name { get; set; } = string.Empty; + public string TypeSql { get; set; } = string.Empty; + public bool Nullable { get; set; } + } + + public sealed class SqlServerMigrationTable + { + public string Name { get; set; } = string.Empty; + public List Columns { get; set; } = new List(); + public long RowCount { get; set; } + } + + public sealed class SqlServerMigrationCatalog + { + public string Identity { get; set; } = string.Empty; + public string DatabaseName { get; set; } = string.Empty; + public int MajorVersion { get; set; } + public string Collation { get; set; } = string.Empty; + public List Blockers { get; set; } = new List(); + public List Tables { get; set; } = new List(); + public long ObjectCount { get; set; } + } + + public sealed class SqlServerMigrationPreview + { + public SqlServerMigrationCatalog Source { get; set; } = new SqlServerMigrationCatalog(); + public SqlServerMigrationCatalog Target { get; set; } = new SqlServerMigrationCatalog(); + public int SelectedTargetMajor { get; set; } + public List Blockers { get; set; } = new List(); + public List Statements { get; set; } = new List(); + public bool CanStart { get { return Blockers.Count == 0; } } + public string Fingerprint { get; set; } = string.Empty; + } + + public interface ISqlServerMigrationSession : IDisposable + { + SqlServerMigrationCatalog ReadCatalog(); + void Begin(bool source); + IEnumerable ReadBatches(SqlServerMigrationTable table, int batchSize, CancellationToken cancellationToken); + void Execute(string sql); + void Insert(SqlServerMigrationTable table, DataTable rows); + void Commit(); + void Rollback(); + } + + public sealed class SqlServerMigrationResult + { + public bool Succeeded { get; set; } + public bool CleanupConfirmed { get; set; } + public long Rows { get; set; } + public string Report { get; set; } = string.Empty; + } + + public static class SqlServerCompatibilityMigration + { + public static readonly int[] SupportedTargetMajors = { 13, 14, 15, 16, 17 }; + public const int MaximumRows = 100000; + + public static string Quote(string name) { return "[" + name.Replace("]", "]]") + "]"; } + + public static SqlServerMigrationPreview Preview(SqlServerMigrationCatalog source, SqlServerMigrationCatalog target, int selectedTargetMajor) + { + var preview = new SqlServerMigrationPreview { Source = source, Target = target, SelectedTargetMajor = selectedTargetMajor }; + preview.Blockers.AddRange(source.Blockers); + preview.Blockers.AddRange(target.Blockers); + if (!SupportedTargetMajors.Contains(target.MajorVersion) || !SupportedTargetMajors.Contains(source.MajorVersion)) + preview.Blockers.Add("僅規劃支援 SQL Server 2016–2025;未知或更早引擎禁止執行。"); + if (target.MajorVersion != selectedTargetMajor) preview.Blockers.Add("選取的最低目標版本與實際引擎版本不符。"); + if (source.MajorVersion <= target.MajorVersion) preview.Blockers.Add("此工具只接受新版來源搬至舊版目標。"); + if (string.IsNullOrWhiteSpace(source.Identity) || string.IsNullOrWhiteSpace(target.Identity) || source.Identity == target.Identity) + preview.Blockers.Add("無法確認來源/目標識別,或兩者為同一資料庫。"); + if (target.ObjectCount != 0 || target.Tables.Count != 0) preview.Blockers.Add("目標必須是使用者另建的空資料庫,不允許覆蓋。"); + if (string.IsNullOrWhiteSpace(source.Collation) || source.Collation != target.Collation) + preview.Blockers.Add("來源與目標定序必須完全相同,禁止隱性轉換。"); + if (source.Tables.Count == 0) preview.Blockers.Add("來源沒有可搬移資料表。"); + if (source.Tables.Sum(table => table.RowCount) > MaximumRows || source.Tables.Any(table => table.RowCount < 0)) + preview.Blockers.Add("來源列數超過 100,000 或無法確認,禁止執行。"); + foreach (var table in source.Tables) + { + if (table.Columns.Count == 0) preview.Blockers.Add("資料表沒有可見欄位。"); + foreach (var column in table.Columns) + if (!IsSupportedType(column.TypeSql)) preview.Blockers.Add("不支援欄位型別:" + table.Name + "." + column.Name); + preview.Statements.Add("CREATE TABLE [dbo]." + Quote(table.Name) + " (" + string.Join(",", table.Columns.Select(column => + Quote(column.Name) + " " + column.TypeSql + (column.Nullable ? " NULL" : " NOT NULL"))) + ");"); + } + preview.Fingerprint = Hash(source.Identity + "|" + target.Identity + "|" + source.DatabaseName + "|" + target.DatabaseName + "|" + source.MajorVersion + "|" + target.MajorVersion + "|" + source.Collation + "|" + + string.Join("\n", preview.Statements) + "|" + string.Join(",", source.Tables.Select(table => table.RowCount)) + "|" + string.Join("\n", preview.Blockers)); + return preview; + } + + public static bool IsSupportedType(string type) + { + if (type == null) return false; + if (!System.Text.RegularExpressions.Regex.IsMatch(type, + @"\A(?:bigint|int|smallint|tinyint|bit|date|datetime|smalldatetime|money|smallmoney|uniqueidentifier|(?:datetime2|time)\([0-7]\)|(?:decimal|numeric)\((?:[1-9]|[12][0-9]|3[0-8]),(?:[0-9]|[12][0-9]|3[0-8])\)|(?:char|varchar|nchar|nvarchar|binary|varbinary)\([1-9][0-9]{0,3}\))\z") + ) return false; + int start = type.IndexOf('('); + if (start < 0) return true; + string[] args = type.Substring(start + 1, type.Length - start - 2).Split(','); + int first = int.Parse(args[0], CultureInfo.InvariantCulture); + if (args.Length == 2) return int.Parse(args[1], CultureInfo.InvariantCulture) <= first; + if (type.StartsWith("nchar(") || type.StartsWith("nvarchar(")) return first <= 4000; + return first <= 8000; + } + + public static SqlServerMigrationResult Run(SqlServerMigrationPreview approved, string confirmation, + ISqlServerMigrationSession source, ISqlServerMigrationSession target, CancellationToken cancellationToken) + { + var result = new SqlServerMigrationResult(); + bool sourceStarted = false, targetStarted = false; + bool commitAttempted = false; + string stage = "確認與預檢"; + try + { + if (!approved.CanStart || !string.Equals(confirmation, approved.Target.DatabaseName, StringComparison.Ordinal)) + throw new InvalidOperationException("必須通過預檢並輸入完整目標資料庫名稱確認。"); + cancellationToken.ThrowIfCancellationRequested(); + sourceStarted = true; source.Begin(true); + targetStarted = true; target.Begin(false); + var current = Preview(source.ReadCatalog(), target.ReadCatalog(), approved.SelectedTargetMajor); + if (!current.CanStart || current.Fingerprint != approved.Fingerprint) + throw new InvalidOperationException("連線識別、版本、結構或空目標已改變,請重新預檢。"); + var report = new StringBuilder(); + foreach (var table in current.Source.Tables) + { + stage = "建立與搬移資料表 " + Quote(table.Name); + cancellationToken.ThrowIfCancellationRequested(); + target.Execute(current.Statements[current.Source.Tables.IndexOf(table)]); + var sourceHashes = new List(); + foreach (var batch in source.ReadBatches(table, 500, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (DataRow row in batch.Rows) + { + if (++result.Rows > MaximumRows) throw new InvalidOperationException("超過本工具每次 100,000 列上限,請交由 DBA 規劃。"); + sourceHashes.Add(RowHash(row)); + } + target.Insert(table, batch); + } + var targetHashes = new List(); + stage = "驗證資料表 " + Quote(table.Name); + foreach (var batch in target.ReadBatches(table, 500, cancellationToken)) + foreach (DataRow row in batch.Rows) + { + if (targetHashes.Count >= sourceHashes.Count) throw new InvalidOperationException("內容驗證出現額外資料列。"); + targetHashes.Add(RowHash(row)); + } + sourceHashes.Sort(StringComparer.Ordinal); targetHashes.Sort(StringComparer.Ordinal); + if (sourceHashes.Count != table.RowCount || !sourceHashes.SequenceEqual(targetHashes)) throw new InvalidOperationException("內容驗證失敗,未提交目標。"); + report.AppendLine(table.Name + ": " + sourceHashes.Count + " 列;逐欄型別/NULL/內容 SHA-256 多重集合相符。"); + } + var expectedTarget = Preview(current.Source, current.Target, current.SelectedTargetMajor); + var written = target.ReadCatalog(); + var actualStatements = Preview(written, current.Target, current.SelectedTargetMajor).Statements; + if (!expectedTarget.Statements.SequenceEqual(actualStatements) || written.Blockers.Count != 0) + throw new InvalidOperationException("目標結構驗證失敗,未提交。"); + if (Preview(source.ReadCatalog(), current.Target, current.SelectedTargetMajor).Fingerprint != current.Fingerprint) + throw new InvalidOperationException("來源結構在搬移期間改變,未提交。"); + cancellationToken.ThrowIfCancellationRequested(); + source.Rollback(); sourceStarted = false; + stage = "提交目標交易"; + commitAttempted = true; + target.Commit(); targetStarted = false; + result.Succeeded = true; result.CleanupConfirmed = true; + result.Report = report.ToString(); + } + catch (Exception exception) + { + bool clean = !commitAttempted; + if (targetStarted) try { target.Rollback(); } catch { clean = false; } + if (sourceStarted) try { source.Rollback(); } catch { clean = false; } + result.CleanupConfirmed = clean; + result.Report = (exception is OperationCanceledException ? "已取消。" : "相容搬移失敗;請重新預檢。") + + (clean ? "目標交易已回滾;沒有自動重試。" : "交易結果不確定;禁止重試,請由 DBA 核對目標。") + + " 階段:" + stage + ";錯誤類型:" + exception.GetType().Name; + } + return result; + } + + private static string Hash(string value) + { + using (var sha = SHA256.Create()) return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(value))); + } + + private static string RowHash(DataRow row) + { + var text = new StringBuilder(); + foreach (object value in row.ItemArray) + { + string encoded; + if (value is DBNull) encoded = "NULL"; + else if (value is byte[]) encoded = "bytes:" + Convert.ToBase64String((byte[])value); + else if (value is DateTime) encoded = "date:" + ((DateTime)value).Ticks.ToString(CultureInfo.InvariantCulture); + else encoded = value.GetType().FullName + ":" + (Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + if (encoded.Length > 1048576) throw new InvalidOperationException("單一欄位超過 1 MiB 驗證上限。"); + text.Append(encoded.Length).Append(':').Append(encoded); + } + return Hash(text.ToString()); + } + } +} diff --git a/mySQLPunk/lib/SqlServerMigrationSession.cs b/mySQLPunk/lib/SqlServerMigrationSession.cs new file mode 100644 index 00000000..bfc2d0c4 --- /dev/null +++ b/mySQLPunk/lib/SqlServerMigrationSession.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.Common; +using System.Data.SqlClient; +using System.Globalization; +using System.Threading; + +namespace mySQLPunk.lib +{ + public sealed class SqlServerMigrationSession : ISqlServerMigrationSession + { + private readonly SqlConnection connection; + private SqlTransaction transaction; + + public SqlServerMigrationSession(SqlConnection existing, string database) + { + var settings = new SqlConnectionStringBuilder(existing.ConnectionString) { InitialCatalog = database }; + connection = new SqlConnection(settings.ConnectionString); + try { connection.Open(); } + catch { connection.Dispose(); throw; } + } + + private DataTable Query(string sql) + { + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; command.CommandText = sql; + using (var reader = command.ExecuteReader()) + { + var table = new DataTable(); table.Load(reader); return table; + } + } + } + + public SqlServerMigrationCatalog ReadCatalog() + { + DataTable engine = Query(@"SELECT CAST(SERVERPROPERTY('ServerName') AS nvarchar(256)) AS server_name, +CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)) AS version, DB_NAME() AS db_name, DB_ID() AS db_id, +create_date, collation_name FROM sys.databases WHERE database_id=DB_ID()"); + if (engine.Rows.Count != 1) throw new InvalidOperationException("無法讀取引擎與資料庫識別。"); + DataRow identity = engine.Rows[0]; + Version version; + if (!Version.TryParse(Convert.ToString(identity["version"]), out version)) throw new InvalidOperationException("未知引擎版本。"); + var catalog = new SqlServerMigrationCatalog + { + DatabaseName = Convert.ToString(identity["db_name"]), MajorVersion = version.Major, + Collation = Convert.ToString(identity["collation_name"]), + Identity = Convert.ToString(identity["server_name"]) + "/" + identity["db_id"] + "/" + Convert.ToDateTime(identity["create_date"]).Ticks + }; + if (version.Major < 13 || version.Major > 17) { catalog.Blockers.Add("未知或未支援的 SQL Server 引擎。"); return catalog; } + DataTable permission = Query("SELECT HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'VIEW DEFINITION') AS visible, HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONTROL') AS controlled"); + if (Convert.ToString(permission.Rows[0]["visible"]) != "1" || Convert.ToString(permission.Rows[0]["controlled"]) != "1") + { + catalog.Blockers.Add("需要既有的資料庫 CONTROL 與 VIEW DEFINITION 權限;工具不會授權或提高權限。"); + return catalog; + } + DataTable objects = Query("SELECT type, name FROM sys.objects WHERE is_ms_shipped=0 ORDER BY object_id"); + catalog.ObjectCount = objects.Rows.Count; + foreach (DataRow row in objects.Rows) + if (Convert.ToString(row["type"]).Trim() != "U") catalog.Blockers.Add("不支援物件(含 constraint/trigger/view/procedure/function/sequence):" + row["name"]); + DataTable unsupported = Query(@"SELECT name FROM sys.types WHERE is_user_defined=1 +UNION ALL SELECT name FROM sys.schemas WHERE name NOT IN ('dbo','guest','sys','INFORMATION_SCHEMA','db_owner','db_accessadmin','db_securityadmin','db_ddladmin','db_backupoperator','db_datareader','db_datawriter','db_denydatareader','db_denydatawriter') +UNION ALL SELECT name FROM sys.partition_schemes +UNION ALL SELECT name FROM sys.assemblies WHERE is_user_defined=1"); + foreach (DataRow row in unsupported.Rows) catalog.Blockers.Add("不支援自訂 schema/型別/分割/CLR:" + row[0]); + if (Query("SELECT major_id FROM sys.extended_properties UNION ALL SELECT object_id FROM sys.change_tracking_tables").Rows.Count != 0) + catalog.Blockers.Add("不支援 extended properties 或 change tracking。"); + if (version.Major >= 14 && Query("SELECT object_id FROM sys.tables WHERE is_node=1 OR is_edge=1").Rows.Count != 0) + catalog.Blockers.Add("不支援 graph tables。"); + if (version.Major >= 16 && Query("SELECT object_id FROM sys.tables WHERE ledger_type<>0").Rows.Count != 0) + catalog.Blockers.Add("不支援 ledger tables。"); + DataTable tables = Query(@"SELECT t.object_id,t.name,s.name AS schema_name,t.is_memory_optimized,t.temporal_type,t.is_filetable,t.is_tracked_by_cdc,t.is_replicated,t.is_merge_published,t.is_sync_tran_subscribed,t.is_remote_data_archive_enabled, + (SELECT COUNT(*) FROM sys.indexes i WHERE i.object_id=t.object_id AND i.index_id>0) AS indexes, + (SELECT COUNT(*) FROM sys.partitions p WHERE p.object_id=t.object_id AND p.index_id=0 AND p.data_compression<>0) AS compressed, + (SELECT COUNT(*) FROM sys.security_predicates p WHERE p.target_object_id=t.object_id) AS security_predicates, + (SELECT COUNT(*) FROM sys.indexes i LEFT JOIN sys.filegroups f ON f.data_space_id=i.data_space_id WHERE i.object_id=t.object_id AND i.index_id=0 AND (f.name IS NULL OR f.name<>'PRIMARY')) AS storage_features +FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id WHERE t.is_ms_shipped=0 ORDER BY t.name"); + foreach (DataRow row in tables.Rows) + { + var table = new SqlServerMigrationTable { Name = Convert.ToString(row["name"]) }; + if (Convert.ToString(row["schema_name"]) != "dbo" || Convert.ToBoolean(row["is_memory_optimized"]) || + Convert.ToInt32(row["temporal_type"]) != 0 || Convert.ToBoolean(row["is_filetable"]) || + Convert.ToBoolean(row["is_tracked_by_cdc"]) || Convert.ToBoolean(row["is_replicated"]) || Convert.ToBoolean(row["is_merge_published"]) || Convert.ToBoolean(row["is_sync_tran_subscribed"]) || Convert.ToBoolean(row["is_remote_data_archive_enabled"]) || + Convert.ToInt32(row["indexes"]) != 0 || Convert.ToInt32(row["compressed"]) != 0 || Convert.ToInt32(row["security_predicates"]) != 0 || Convert.ToInt32(row["storage_features"]) != 0) + catalog.Blockers.Add("只支援 dbo 未壓縮 heap;禁止索引、記憶體表、temporal、FileTable、RLS:" + table.Name); + int objectId = Convert.ToInt32(row["object_id"], CultureInfo.InvariantCulture); + DataTable columns = Query(@"SELECT c.name,t.name AS type_name,c.max_length,c.precision,c.scale,c.is_nullable,c.collation_name, +c.is_identity,c.is_computed,c.is_sparse,c.is_column_set,c.is_rowguidcol,c.generated_always_type,c.encryption_type,c.is_ansi_padded, +c.is_filestream,c.is_hidden,ISNULL(m.is_masked,0) AS is_masked FROM sys.columns c JOIN sys.types t ON t.user_type_id=c.user_type_id +LEFT JOIN sys.masked_columns m ON m.object_id=c.object_id AND m.column_id=c.column_id +WHERE c.object_id=" + objectId.ToString(CultureInfo.InvariantCulture) + " ORDER BY c.column_id"); + foreach (DataRow column in columns.Rows) + { + string type = Convert.ToString(column["type_name"]); + int length = Convert.ToInt32(column["max_length"]); + if ((type == "char" || type == "varchar" || type == "binary" || type == "varbinary") && !Convert.ToBoolean(column["is_ansi_padded"])) + catalog.Blockers.Add("不支援 ANSI_PADDING OFF 欄位:" + table.Name + "." + column["name"]); + if (type == "decimal" || type == "numeric") type += "(" + column["precision"] + "," + column["scale"] + ")"; + else if (type == "datetime2" || type == "time") type += "(" + column["scale"] + ")"; + else if (type == "char" || type == "varchar" || type == "binary" || type == "varbinary" || type == "nchar" || type == "nvarchar") + type += "(" + ((type == "nchar" || type == "nvarchar") ? length / 2 : length) + ")"; + foreach (string flag in new[] { "is_identity", "is_computed", "is_sparse", "is_column_set", "is_rowguidcol", "is_filestream", "is_hidden", "is_masked" }) + if (Convert.ToBoolean(column[flag])) catalog.Blockers.Add("不支援欄位 feature " + flag + ":" + table.Name + "." + column["name"]); + if (Convert.ToInt32(column["generated_always_type"]) != 0 || !(column["encryption_type"] is DBNull)) + catalog.Blockers.Add("不支援 generated/encrypted 欄位:" + table.Name + "." + column["name"]); + if (!(column["collation_name"] is DBNull) && Convert.ToString(column["collation_name"]) != catalog.Collation) + catalog.Blockers.Add("欄位定序不是資料庫預設定序:" + table.Name + "." + column["name"]); + if (length > 8000) catalog.Blockers.Add("不支援長欄位:" + table.Name + "." + column["name"]); + table.Columns.Add(new SqlServerMigrationColumn { Name = Convert.ToString(column["name"]), TypeSql = type, Nullable = Convert.ToBoolean(column["is_nullable"]) }); + } + DataTable count = Query("SELECT COUNT_BIG(*) FROM [dbo]." + SqlServerCompatibilityMigration.Quote(table.Name)); + table.RowCount = Convert.ToInt64(count.Rows[0][0], CultureInfo.InvariantCulture); + catalog.Tables.Add(table); + } + return catalog; + } + + public void Begin(bool source) + { + transaction = connection.BeginTransaction(IsolationLevel.Serializable); + if (!source) Execute("SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON; SET ANSI_PADDING ON;"); + if (!source) Execute("DECLARE @result int; EXEC @result=sys.sp_getapplock @Resource=N'mySQLPunk:compatibility-migration', @LockMode='Exclusive', @LockOwner='Transaction', @LockTimeout=0; IF @result<0 THROW 50000, 'Migration busy', 1;"); + } + + public IEnumerable ReadBatches(SqlServerMigrationTable table, int batchSize, CancellationToken cancellationToken) + { + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = "SELECT " + string.Join(",", table.Columns.ConvertAll(column => SqlServerCompatibilityMigration.Quote(column.Name))) + " FROM [dbo]." + SqlServerCompatibilityMigration.Quote(table.Name); + using (cancellationToken.Register(command.Cancel)) + using (var reader = command.ExecuteReader()) + { + var batch = new DataTable(); + for (int column = 0; column < reader.FieldCount; column++) batch.Columns.Add(reader.GetName(column), reader.GetFieldType(column)); + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + var values = new object[reader.FieldCount]; reader.GetValues(values); batch.Rows.Add(values); + if (batch.Rows.Count == batchSize) { yield return batch; batch = batch.Clone(); } + } + if (batch.Rows.Count > 0) yield return batch; + } + } + } + + public void Execute(string sql) + { + using (var command = connection.CreateCommand()) { command.Transaction = transaction; command.CommandText = sql; command.ExecuteNonQuery(); } + } + public void Insert(SqlServerMigrationTable table, DataTable rows) + { + SqlServerBatchInsert.Execute(connection, "[dbo]." + SqlServerCompatibilityMigration.Quote(table.Name), rows, transaction); + } + public void Commit() { transaction.Commit(); transaction.Dispose(); transaction = null; } + public void Rollback() { if (transaction != null) { transaction.Rollback(); transaction.Dispose(); transaction = null; } } + public void Dispose() { if (transaction != null) transaction.Dispose(); connection.Dispose(); } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 7caa6901..9abbac60 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -450,6 +450,9 @@ + + + Form diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index a06178e9..1066dbb1 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -145,6 +145,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.IO.Compression.FileSystem.dll ` (Join-Path $PSScriptRoot "SqlServerBatchInsertTests.cs") ` (Join-Path $PSScriptRoot "SqlServerRestoreCompatibilityTests.cs") ` + (Join-Path $PSScriptRoot "SqlServerMigrationTests.cs") ` (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.TransferTrial.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Live.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Wizard.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlImport.Parser.cs") $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Snapshot.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.ViewReferences.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Properties.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") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Routing.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.SnapshotIntegration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.CompatibilityIntegration.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 6c54f72c..9dfa0966 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -13220,6 +13220,7 @@ private static void TestDataTransfer() SqlServerBatchInsertTests.Run(batchConnection); } SqlServerRestoreCompatibilityTests.Run(); + SqlServerMigrationTests.Run(); string dir = Path.Combine(Path.GetTempPath(), "mysqlpunk-transfer-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try diff --git a/tests/SqlServerMigrationTests.cs b/tests/SqlServerMigrationTests.cs new file mode 100644 index 00000000..2b5ac3e5 --- /dev/null +++ b/tests/SqlServerMigrationTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using mySQLPunk.lib; + +public static class SqlServerMigrationTests +{ + private static void Check(bool condition, string message) { if (!condition) throw new Exception(message); } + public static void Run() + { + foreach (string type in new[] { "int", "nvarchar(4000)", "decimal(38,38)", "datetime2(7)", "varbinary(8000)" }) Check(SqlServerCompatibilityMigration.IsSupportedType(type), "Supported type " + type); + foreach (string type in new[] { "vector", "xml", "nvarchar(max)", "nvarchar(4001)", "varchar(8001)", "decimal(2,3)", "int);DROP TABLE x--" }) Check(!SqlServerCompatibilityMigration.IsSupportedType(type), "Unsafe type " + type); + foreach (int major in SqlServerCompatibilityMigration.SupportedTargetMajors.Where(value => value < 17)) + Check(SqlServerCompatibilityMigration.Preview(Source().Catalog, Target(major).Catalog, major).CanStart, "Declared matrix " + major); + var from = Source(); var to = Target(15); + var plan = SqlServerCompatibilityMigration.Preview(from.Catalog, to.Catalog, 15); + Check(plan.CanStart && plan.Statements.Single().Contains("[na]]me]"), "Preview quoted DDL"); + Check(!SqlServerCompatibilityMigration.Run(plan, "wrong", from, to, CancellationToken.None).Succeeded && to.Begins == 0, "Confirmation precedes writes"); + var success = SqlServerCompatibilityMigration.Run(plan, "empty", from, to, CancellationToken.None); + Check(success.Succeeded && success.Rows == 1001 && to.Commits == 1 && to.Batches == 3, "Batched verified migration"); + foreach (string failure in new[] { "write", "content", "schema", "begin", "cancel", "cancel_midway", "rollback", "commit", "commit_ack_lost" }) + { + from = Source(); to = Target(15); plan = SqlServerCompatibilityMigration.Preview(from.Catalog, to.Catalog, 15); + to.Failure = failure; + using (var cancellation = new CancellationTokenSource()) + { + if (failure == "cancel") cancellation.Cancel(); + if (failure == "cancel_midway") to.Stop = cancellation.Cancel; + var result = SqlServerCompatibilityMigration.Run(plan, "empty", from, to, cancellation.Token); + Check(!result.Succeeded && to.Commits == 0, "Failure never succeeds " + failure); + Check(result.CleanupConfirmed == (failure != "rollback" && failure != "commit" && failure != "commit_ack_lost"), "Cleanup certainty " + failure); + if (result.CleanupConfirmed) Check(to.Rows.Rows.Count == 0, "Failure rollback rows " + failure); + if (failure == "commit_ack_lost") Check(to.Rows.Rows.Count == 1001, "Lost commit acknowledgement can leave committed rows"); + } + } + from = Source(); to = Target(15); plan = SqlServerCompatibilityMigration.Preview(from.Catalog, to.Catalog, 15); + to.Catalog.ObjectCount = 1; + Check(!SqlServerCompatibilityMigration.Run(plan, "empty", from, to, CancellationToken.None).Succeeded && to.Batches == 0, "Changed target denied"); + from = Source(); to = Target(15); to.Catalog.Collation = "different"; + Check(!SqlServerCompatibilityMigration.Preview(from.Catalog, to.Catalog, 15).CanStart, "Collation denied"); + foreach (string feature in new[] { "identity", "constraint", "index", "trigger", "procedure", "schema", "ledger" }) + { + from = Source(); from.Catalog.Blockers.Add(feature); + Check(!SqlServerCompatibilityMigration.Preview(from.Catalog, Target(15).Catalog, 15).CanStart, "Feature blocked " + feature); + } + Check(!SqlServerCompatibilityMigration.Preview(Source().Catalog, Target(12).Catalog, 12).CanStart, "Unknown/old target denied"); + Check(!SqlServerCompatibilityMigration.Preview(Source().Catalog, Target(15).Catalog, 13).CanStart, "Explicit version mismatch denied"); + } + private static MockSession Source() + { + var session = Target(17); session.Catalog.Identity = "source/1"; session.Catalog.DatabaseName = "source"; + var table = new SqlServerMigrationTable { Name = "na]me", RowCount = 1001 }; + table.Columns.Add(new SqlServerMigrationColumn { Name = "id", TypeSql = "int" }); + table.Columns.Add(new SqlServerMigrationColumn { Name = "value", TypeSql = "nvarchar(30)", Nullable = true }); + session.Catalog.Tables.Add(table); session.Catalog.ObjectCount = 1; + for (int index = 0; index < 1001; index++) session.Rows.Rows.Add(index, index % 2 == 0 ? (object)DBNull.Value : "中文"); + return session; + } + private static MockSession Target(int major) + { + var session = new MockSession { Catalog = new SqlServerMigrationCatalog { Identity = "target/2", DatabaseName = "empty", MajorVersion = major, Collation = "same" } }; + session.Rows.Columns.Add("id", typeof(int)); session.Rows.Columns.Add("value", typeof(string)); return session; + } + private sealed class MockSession : ISqlServerMigrationSession + { + public SqlServerMigrationCatalog Catalog = new SqlServerMigrationCatalog(); + public DataTable Rows = new DataTable(); + public string Failure = string.Empty; + public int Begins, Commits, Batches; + public Action Stop = () => { }; + public SqlServerMigrationCatalog ReadCatalog() + { + return new SqlServerMigrationCatalog + { + Identity = Catalog.Identity, DatabaseName = Catalog.DatabaseName, MajorVersion = Catalog.MajorVersion, + Collation = Catalog.Collation, ObjectCount = Catalog.ObjectCount, Blockers = Catalog.Blockers.ToList(), + Tables = Catalog.Tables.Select(table => new SqlServerMigrationTable + { + Name = table.Name, RowCount = table.RowCount, + Columns = table.Columns.Select(column => new SqlServerMigrationColumn { Name = column.Name, TypeSql = column.TypeSql, Nullable = column.Nullable }).ToList() + }).ToList() + }; + } + public void Begin(bool source) { Begins++; if (Failure == "begin") throw new InvalidOperationException(); } + public IEnumerable ReadBatches(SqlServerMigrationTable table, int batchSize, CancellationToken cancellationToken) + { + for (int offset = 0; offset < Rows.Rows.Count; offset += batchSize) + { + var batch = Rows.Clone(); + for (int index = offset; index < Math.Min(offset + batchSize, Rows.Rows.Count); index++) batch.ImportRow(Rows.Rows[index]); + if (Failure == "content") batch.Rows[0]["value"] = "corrupt"; + yield return batch; + } + } + public void Execute(string sql) + { + var table = Source().Catalog.Tables.Single(); table.RowCount = 0; Catalog.Tables.Add(table); Catalog.ObjectCount = 1; + if (Failure == "schema") table.Columns[0].TypeSql = "bigint"; + } + public void Insert(SqlServerMigrationTable table, DataTable rows) + { + Batches++; + foreach (DataRow row in rows.Rows) Rows.ImportRow(row); + Catalog.Tables[0].RowCount = Rows.Rows.Count; + if (Batches == 2) Stop(); + if ((Failure == "write" || Failure == "rollback") && Batches == 2) throw new InvalidOperationException(); + } + public void Commit() { if (Failure == "commit" || Failure == "commit_ack_lost") throw new InvalidOperationException(); Commits++; } + public void Rollback() + { + if (Failure == "rollback" || Failure == "commit") throw new InvalidOperationException(); + if (Failure == "commit_ack_lost") return; + if (Catalog.Identity.StartsWith("target")) { Rows.Clear(); Catalog.Tables.Clear(); Catalog.ObjectCount = 0; } + } + public void Dispose() { } + } +}