diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index e019611..ce6609a 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -9,6 +9,8 @@ on: - "mySQLPunk.Desktop/**" - "mySQLPunk.CrossPlatform.SmokeTests/**" - "tests/Run-CrossPlatformLiveTests.sh" + - "tests/Run-DesktopProfileOperationTests.sh" + - "tests/DesktopProfileOperationHarness/**" - "tests/Test-LinuxCrossPlatformPackage.sh" - "tests/Test-LinuxUpdateApply.sh" - "tests/Test-MacCrossPlatformPackage.sh" @@ -22,6 +24,8 @@ on: - "mySQLPunk.Desktop/**" - "mySQLPunk.CrossPlatform.SmokeTests/**" - "tests/Run-CrossPlatformLiveTests.sh" + - "tests/Run-DesktopProfileOperationTests.sh" + - "tests/DesktopProfileOperationHarness/**" - "tests/Test-LinuxCrossPlatformPackage.sh" - "tests/Test-LinuxUpdateApply.sh" - "tests/Test-MacCrossPlatformPackage.sh" @@ -78,6 +82,10 @@ jobs: - name: Run cross-platform smoke tests run: dotnet run --project mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj -c Release --no-build + - name: Run isolated Avalonia profile operation handlers + if: matrix.name == 'Linux x64' + run: ./tests/Run-DesktopProfileOperationTests.sh + - name: Run MySQL, MariaDB, PostgreSQL and SQL Server live round-trip if: matrix.live_tests == true run: ./tests/Run-CrossPlatformLiveTests.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ed16dd3..f832d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,12 @@ ### 🛠️ 問題修正與優化 +- SQL Server 相容搬移改用既有連線設定工廠,正常 SQL authentication 不再受已開啟連線隱藏密碼影響,SSH tunnel 與 provider owner 一起釋放。 +- 桌面編輯、刪除與準備連線全程持有操作 gate,等待密碼或確認對話框時也不允許 SQL 重入;取消後不再更新設定或斷開現有 session。 +- 桌面操作回歸直接啟動 Avalonia handlers,使用隔離 HOME/設定與 SQLite 檢查忙碌、等待對話框與取消,也驗證閒置時仍可正常儲存及刪除連線設定;Linux x64 CI 同步執行。 +- 資料傳輸的續傳提示、執行清單與完成判斷共用驗證 predicate,舊版 Done 但未驗證的檢查點不再被 UI 漏掉;已驗證失敗或禁止重試的項目仍保留安全阻擋,不重複寫入。 +- SQL Server 相容搬移的來源與目標都查詢資料庫層級 DDL trigger,包含停用項目;發現 trigger 或無法讀取 metadata 就停止,不讓 CREATE TABLE/INSERT 帶出未預期行為。 + - SQL Server 批次新增依 2,100 參數與 1,000 VALUES 列上限切批,整次呼叫共用交易,中途失敗不留下前批資料。 - 桌面連線測試、取得資料庫或取消失敗時釋放尚未移交的 session,避免 SSH tunnel 洩漏。 - 桌面 SQL 快捷鍵與操作共用互斥 gate,取消後等待舊操作結束才接受新操作,取消結果不再覆蓋畫面。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1b2809..5e0be71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,7 @@ git push origin master - 動到跨平台打包、安裝或 release workflow 時,至少用 `./scripts/package-cross-platform.sh --version <版本> --runtime linux-x64` 產生實際 self-contained 資產,再執行 `MYSQLPUNK_PACKAGE_RUN_UI=1 ./tests/Test-LinuxCrossPlatformPackage.sh `,確認隔離安裝、Xvfb 啟動、解除安裝與路徑安全檢查皆通過;Linux x64/ARM64 另由 `ubuntu-24.04`/`ubuntu-24.04-arm` 原生 CI runner 執行安裝、UI 啟動、安全更新與 rollback。macOS app bundle、plist、CPU 架構、codesign、zip metadata、安全套用與啟動健康檢查,則分別由 `macos-15-intel` 與 `macos-15` 原生 runner 驗證。 - 動到 provider 實際行為(匯出匯入、使用者管理、rename)時,視影響跑對應的 Docker 整合測試:`Run-MySqlUserIntegrationTests.ps1`、`Run-MySqlExportRenameIntegrationTests.ps1`、`Run-DatabaseRenameProviderIntegrationTests.ps1`。 - UI 有變的話,除了測試,最好實際開起來看一眼。 +- 桌面連線操作有變時,Linux 執行 `./tests/Run-DesktopProfileOperationTests.sh`:以隔離設定與 SQLite 直接操作 Avalonia handlers,驗證忙碌入口、對話框 lease、取消後保留 session,以及正常編輯/刪除;不讀取使用者的連線或秘密。 ## Commit message diff --git a/docs/MSSQL_COMPATIBILITY_MIGRATION.md b/docs/MSSQL_COMPATIBILITY_MIGRATION.md index 2a0073a..c6c0405 100644 --- a/docs/MSSQL_COMPATIBILITY_MIGRATION.md +++ b/docs/MSSQL_COMPATIBILITY_MIGRATION.md @@ -25,13 +25,14 @@ UI 不預選引擎,必須明確選取目標,並與 SERVERPROPERTY 實際版 7. 逐列以型別、NULL、值的 SHA-256 多重集合核對內容與列數,並重新檢查來源及目標結構。全部通過才提交。 8. 每次最多 100,000 列,單一欄位編碼最多 1 MiB;不是大型資料庫 bulk migration。失敗/取消回滾,不自動重試。提交回覆遺失、回滾或清理失敗時,明確回報結果不確定,必須由 DBA 核對再決定下一步。 -獨立 SQL 連線沿用目前連線設定。若驅動程式已移除 SQL authentication 密碼,獨立連線可能無法建立;工具不讀取密碼檔或秘密庫來繞過,請以可建立獨立連線的正常認證方式重新連線。 +獨立 SQL 連線使用目前選取連線的記憶體設定,透過既有匯入連線工廠開啟,不從已開啟連線的 ConnectionString 重建。SQL authentication 與 SSH 沿用正常連線流程;provider owner 同時管理連線與 SSH tunnel,搬移結束或失敗後釋放。工具不另外讀取秘密庫、不開啟 PersistSecurityInfo,也不輸出密碼。 ## 支援與 fail closed 只搬移 dbo、PRIMARY filegroup、未壓縮的一般 heap 的欄位與資料。保留名稱、型別長度/精度/scale、NULL。明確阻擋: - identity、computed、default/check/PK/FK/unique constraints、所有索引(包含停用索引)、triggers、procedures、functions、views、sequences。 +- 來源與目標另查詢 `sys.triggers` 的 `parent_class=0`,拒絕所有資料庫層級 DDL trigger(包含停用的 trigger),不依賴 `sys.objects` 推測資料庫為空;查詢失敗也停止。catalog 回歸直接餵入查詢 seam,驗證來源/目標、啟用/停用與讀取失敗,而非手動加入 blocker。 - 自訂 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 欄位或查詢錯誤也停止。 diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index e74bbda..15ea07d 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -14,8 +14,10 @@ ("SQL Server 分批交易與重試", BatchInsertAsync), ("SQL Server 原生還原版本預檢", RestoreCompatibilityAsync), ("SQL Server 降版預檢與交易 mock", MigrationAsync), + ("SQL Server 資料庫 DDL trigger catalog 預檢", MigrationCatalogAsync), ("桌面 session 失敗與取消所有權", SessionOwnershipAsync), ("桌面操作互斥與取消結果防護", OperationOwnershipAsync), + ("連線編輯刪除與等待對話框的操作互斥", ProfileMutationOwnershipAsync), ("連線設定不保存密碼", ProfileStoreDoesNotPersistPasswordsAsync), ("連線 URI 安全匯入", ConnectionUriImportAsync), ("TLS 憑證檔案安全套用", TlsCertificateFilesAsync), @@ -62,6 +64,43 @@ static Task MigrationAsync() return Task.CompletedTask; } +static Task MigrationCatalogAsync() +{ + SqlServerMigrationCatalogTests.Run(); + return Task.CompletedTask; +} + +static async Task ProfileMutationOwnershipAsync() +{ + foreach (var mutation in new[] { "edit", "delete", "connect" }) + { + var gate = new OperationGate(); + using (var sqlLease = gate.TryEnter()) + Assert(gate.TryEnter() is null, $"Busy SQL rejects {mutation} at entry"); + var dialog = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var changes = 0; + async Task MutateAsync() + { + using var lease = gate.TryEnter(); + Assert(lease is not null, "Dialog preparation obtains shared operation lease"); + await dialog.Task; + cancellation.Token.ThrowIfCancellationRequested(); + changes++; + } + var pending = MutateAsync(); + foreach (var competing in new[] { "SQL", "connect", "edit", "delete" }) + Assert(gate.TryEnter() is null, $"{mutation} dialog blocks competing {competing}"); + cancellation.Cancel(); + Assert(gate.TryEnter() is null, "Cancel does not release awaiting profile operation"); + dialog.SetResult(true); + try { await pending; } catch (OperationCanceledException) { } + Assert(changes == 0, "Cancelled dialog cannot save profile or disconnect session"); + using var next = gate.TryEnter(); + Assert(next is not null, "Profile operation releases lease after dialog returns"); + } +} + 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 650a631..a79fe53 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj +++ b/mySQLPunk.CrossPlatform.SmokeTests/mySQLPunk.CrossPlatform.SmokeTests.csproj @@ -16,6 +16,8 @@ + + diff --git a/mySQLPunk.Desktop/MainWindow.axaml.cs b/mySQLPunk.Desktop/MainWindow.axaml.cs index f7f31d9..886a62e 100644 --- a/mySQLPunk.Desktop/MainWindow.axaml.cs +++ b/mySQLPunk.Desktop/MainWindow.axaml.cs @@ -489,75 +489,92 @@ private async void AddProfile_Click(object? sender, RoutedEventArgs e) private async void EditProfile_Click(object? sender, RoutedEventArgs e) { + if (_operationCancellation is not null) return; if (_profilesList.SelectedItem is not ConnectionProfile selected) { return; } - var editable = selected.Clone(); - var passwordResolution = await ResolvePasswordAsync(selected); - if (passwordResolution.Found) + await RunOperationAsync("正在編輯連線設定…", async cancellationToken => { - editable.Password = passwordResolution.Password; - } + var editable = selected.Clone(); + var passwordResolution = await ResolvePasswordAsync(selected); + cancellationToken.ThrowIfCancellationRequested(); + if (passwordResolution.Found) + { + editable.Password = passwordResolution.Password; + } - if (await ResolveSshSecretsAsync(selected)) - { - ApplySshSecrets(editable); - } + var sshResolved = await ResolveSshSecretsAsync(selected); + cancellationToken.ThrowIfCancellationRequested(); + if (sshResolved) + { + ApplySshSecrets(editable); + } - var editor = new ConnectionEditorWindow(editable, passwordResolution.Warning, _secretStore); - var result = await editor.ShowDialog(this); - if (result is null) - { - return; - } + var editor = new ConnectionEditorWindow(editable, passwordResolution.Warning, _secretStore); + var result = await editor.ShowDialog(this); + cancellationToken.ThrowIfCancellationRequested(); + if (result is null) + { + return; + } - var index = _profiles.IndexOf(selected); - var passwordStatus = await ApplyPasswordPreferenceAsync(selected, result); - passwordStatus += await ApplySshSecretPreferenceAsync(selected, result); - RememberSshSecrets(result); - result.Password = string.Empty; - _profiles[index] = result; - _profilesList.SelectedIndex = index; - await SaveProfilesAsync(); - Disconnect($"已更新「{result.Name}」;{passwordStatus} 請重新連線。"); + var index = _profiles.IndexOf(selected); + var passwordStatus = await ApplyPasswordPreferenceAsync(selected, result); + cancellationToken.ThrowIfCancellationRequested(); + passwordStatus += await ApplySshSecretPreferenceAsync(selected, result); + cancellationToken.ThrowIfCancellationRequested(); + RememberSshSecrets(result); + result.Password = string.Empty; + _profiles[index] = result; + _profilesList.SelectedIndex = index; + await SaveProfilesAsync(); + Disconnect($"已更新「{result.Name}」;{passwordStatus} 請重新連線。"); + }); } private async void DeleteProfile_Click(object? sender, RoutedEventArgs e) { + if (_operationCancellation is not null) return; if (_profilesList.SelectedItem is not ConnectionProfile selected) { return; } - var confirmed = await MessageDialog.ShowAsync( - this, - "刪除連線設定", - $"確定要刪除「{selected.Name}」嗎?資料庫本身不會被刪除。", - showCancel: true); - if (!confirmed) + await RunOperationAsync("正在刪除連線設定…", async cancellationToken => { - return; - } + var confirmed = await MessageDialog.ShowAsync( + this, + "刪除連線設定", + $"確定要刪除「{selected.Name}」嗎?資料庫本身不會被刪除。", + showCancel: true); + cancellationToken.ThrowIfCancellationRequested(); + if (!confirmed) + { + return; + } - var wasConnected = _session?.Profile.Id == selected.Id; - _runtimePasswords.Remove(selected.Id); - _runtimeSshSecrets.Remove(selected.Id); - var secretWarning = selected.UseSecretStore - ? await DeleteStoredPasswordAsync(selected.Id) - : string.Empty; - var preferenceWarning = await DeleteTableColumnPreferencesAsync(selected.Id); - _profiles.Remove(selected); - await SaveProfilesAsync(); - if (wasConnected) - { - Disconnect($"連線設定已刪除。{secretWarning}{preferenceWarning}"); - } - else - { - SetStatus($"連線設定已刪除。{secretWarning}{preferenceWarning}"); - } + var wasConnected = _session?.Profile.Id == selected.Id; + var secretWarning = selected.UseSecretStore + ? await DeleteStoredPasswordAsync(selected.Id) + : string.Empty; + cancellationToken.ThrowIfCancellationRequested(); + var preferenceWarning = await DeleteTableColumnPreferencesAsync(selected.Id); + cancellationToken.ThrowIfCancellationRequested(); + _runtimePasswords.Remove(selected.Id); + _runtimeSshSecrets.Remove(selected.Id); + _profiles.Remove(selected); + await SaveProfilesAsync(); + if (wasConnected) + { + Disconnect($"連線設定已刪除。{secretWarning}{preferenceWarning}"); + } + else + { + SetStatus($"連線設定已刪除。{secretWarning}{preferenceWarning}"); + } + }); UpdateActionState(); } @@ -612,15 +629,15 @@ private async void ConnectProfile_Click(object? sender, RoutedEventArgs e) return; } - var connectionProfile = await PrepareConnectionProfileAsync(selected, this); - if (connectionProfile is null) + await RunOperationAsync($"正在準備連線至 {selected.Name}…", async cancellationToken => { - return; - } + var connectionProfile = await PrepareConnectionProfileAsync(selected, this); + cancellationToken.ThrowIfCancellationRequested(); + if (connectionProfile is null) + { + return; + } - if (_operationCancellation is not null) return; - await RunOperationAsync($"正在連線至 {connectionProfile.Name}…", async cancellationToken => - { Disconnect($"準備連線至 {connectionProfile.Name}…"); using var lease = new SessionLease(DatabaseProviderFactory.Create(connectionProfile)); var session = lease.Session; diff --git a/mySQLPunk/DataTransferForm.cs b/mySQLPunk/DataTransferForm.cs index a248b0d..bbb2b4d 100644 --- a/mySQLPunk/DataTransferForm.cs +++ b/mySQLPunk/DataTransferForm.cs @@ -175,7 +175,7 @@ public void LoadTables() resumeButton.Visible = unfinished != null; RenderPlan(); statusLabel.Text = unfinished != null - ? Localization.Format("Transfer.ResumeFound", unfinished.Items.Count(item => item.Include && item.Status != TransferItemStatus.Done), unfinished.UpdatedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) + ? Localization.Format("Transfer.ResumeFound", unfinished.Items.Count(item => item.NeedsProcessing), unfinished.UpdatedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) : Localization.Format("Transfer.TablesFound", plan.Items.Count); } @@ -221,7 +221,7 @@ private async void StartAsync(bool resume) { if (running || plan == null) return; if (resume) UseUnfinished(); - List included = plan.Items.Where(item => item.Include && item.Status != TransferItemStatus.Done).ToList(); + List included = plan.Items.Where(item => item.NeedsProcessing).ToList(); if (included.Count == 0) { statusLabel.Text = Localization.T("Transfer.NothingSelected"); diff --git a/mySQLPunk/Form1.cs b/mySQLPunk/Form1.cs index 9430ef7..3690c97 100644 --- a/mySQLPunk/Form1.cs +++ b/mySQLPunk/Form1.cs @@ -14967,7 +14967,8 @@ private SchemaComparisonEndpoint BuildSchemaComparisonEndpoint(TreeDatabaseTarge ConnectionName = GetConnectionValue(target.ConnectionInfo, "conn_name"), DatabaseName = target.DatabaseName, ProviderName = target.ProviderName, - Database = target.Database + Database = target.Database, + ConnectionSettings = target.ConnectionInfo }; } @@ -14990,7 +14991,8 @@ private List GetOpenSchemaComparisonEndpoints() ConnectionName = GetConnectionValue(connectionInfo, "conn_name"), DatabaseName = databaseNode.Text, ProviderName = database.ProviderName, - Database = database + Database = database, + ConnectionSettings = connectionInfo }); } } diff --git a/mySQLPunk/SchemaComparisonForm.cs b/mySQLPunk/SchemaComparisonForm.cs index b10902b..5041dca 100644 --- a/mySQLPunk/SchemaComparisonForm.cs +++ b/mySQLPunk/SchemaComparisonForm.cs @@ -14,6 +14,7 @@ public sealed class SchemaComparisonEndpoint public string DatabaseName { get; set; } public string ProviderName { get; set; } public IDatabase Database { get; set; } + internal System.Collections.Generic.Dictionary ConnectionSettings { get; set; } public string DisplayName { diff --git a/mySQLPunk/SqlServerMigrationForm.cs b/mySQLPunk/SqlServerMigrationForm.cs index ff0c54a..1c5866a 100644 --- a/mySQLPunk/SqlServerMigrationForm.cs +++ b/mySQLPunk/SqlServerMigrationForm.cs @@ -49,7 +49,7 @@ 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); + return new SqlServerMigrationSession(endpoint.ConnectionSettings, endpoint.DatabaseName); } private async void Inspect(object sender, EventArgs args) diff --git a/mySQLPunk/lib/DataTransferService.cs b/mySQLPunk/lib/DataTransferService.cs index fa6e05c..d057831 100644 --- a/mySQLPunk/lib/DataTransferService.cs +++ b/mySQLPunk/lib/DataTransferService.cs @@ -60,6 +60,10 @@ public TransferItem() public TransferItemStatus Status { get; set; } public long CopiedRows { get; set; } public bool RetryBlocked { get; set; } + [JsonIgnore] + public bool IsComplete { get { return Status == TransferItemStatus.Done && Verified == true && !RetryBlocked; } } + [JsonIgnore] + public bool NeedsProcessing { get { return Include && !IsComplete; } } public long SourceRows { get; set; } public long TargetRowsBefore { get; set; } public long TargetRowsAfter { get; set; } @@ -95,7 +99,7 @@ public TransferPlan() [JsonIgnore] public bool IsFinished { - get { return Items.Where(item => item.Include).All(item => item.Status == TransferItemStatus.Done && item.Verified == true); } + get { return !Items.Any(item => item.NeedsProcessing); } } public string RouteKey() @@ -177,7 +181,7 @@ public static void Run( Action checkpoint, CancellationToken cancellationToken) { - List items = plan.Items.Where(item => item.Include && (item.Status != TransferItemStatus.Done || item.Verified != true)).ToList(); + List items = plan.Items.Where(item => item.NeedsProcessing).ToList(); int index = 0; foreach (TransferItem item in items) { diff --git a/mySQLPunk/lib/SqlServerMigrationCatalogReader.cs b/mySQLPunk/lib/SqlServerMigrationCatalogReader.cs new file mode 100644 index 0000000..49f6de9 --- /dev/null +++ b/mySQLPunk/lib/SqlServerMigrationCatalogReader.cs @@ -0,0 +1,95 @@ +using System; +using System.Data; +using System.Globalization; + +namespace mySQLPunk.lib +{ + internal static class SqlServerMigrationCatalogReader + { + internal static SqlServerMigrationCatalog Read(Func query) + { + 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 databaseTriggers = query("SELECT name,is_disabled FROM sys.triggers WHERE parent_class=0"); + foreach (DataRow trigger in databaseTriggers.Rows) + catalog.Blockers.Add("不支援資料庫層級 DDL trigger(包含停用的 trigger):" + trigger["name"]); + 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; + } + } +} diff --git a/mySQLPunk/lib/SqlServerMigrationSession.cs b/mySQLPunk/lib/SqlServerMigrationSession.cs index bfc2d0c..8d6cd20 100644 --- a/mySQLPunk/lib/SqlServerMigrationSession.cs +++ b/mySQLPunk/lib/SqlServerMigrationSession.cs @@ -5,20 +5,35 @@ using System.Data.SqlClient; using System.Globalization; using System.Threading; +using mySQLPunk.lib.MssqlExport; namespace mySQLPunk.lib { public sealed class SqlServerMigrationSession : ISqlServerMigrationSession { private readonly SqlConnection connection; + private readonly MssqlOwnedSession owner; private SqlTransaction transaction; - public SqlServerMigrationSession(SqlConnection existing, string database) + internal SqlServerMigrationSession(Dictionary settings, string database, + Func, MssqlOwnedSession> factory = null) { - var settings = new SqlConnectionStringBuilder(existing.ConnectionString) { InitialCatalog = database }; - connection = new SqlConnection(settings.ConnectionString); - try { connection.Open(); } - catch { connection.Dispose(); throw; } + if (settings == null) throw new ArgumentNullException("settings"); + var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in settings) + { + if (string.Equals(pair.Key, "pdo", StringComparison.OrdinalIgnoreCase) || + string.Equals(pair.Key, "connString", StringComparison.OrdinalIgnoreCase) || + string.Equals(pair.Key, "isConnect", StringComparison.OrdinalIgnoreCase)) continue; + if (pair.Value == null || pair.Value is string || pair.Value.GetType().IsPrimitive || pair.Value is decimal) + selected[pair.Key] = pair.Value; + } + selected["db_kind"] = "mssql"; + selected["provider"] = "mssql"; + selected["initial_database"] = database; + owner = (factory ?? ConnectionOpenService.OpenMssqlImportSession)(selected); + if (owner == null) throw new InvalidOperationException("連線工廠未回傳 SQL Server session。"); + connection = owner.Connection; } private DataTable Query(string sql) @@ -35,85 +50,7 @@ private DataTable Query(string sql) 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; + return SqlServerMigrationCatalogReader.Read(Query); } public void Begin(bool source) @@ -155,6 +92,10 @@ public void Insert(SqlServerMigrationTable table, DataTable rows) } 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(); } + public void Dispose() + { + try { if (transaction != null) transaction.Dispose(); } + finally { owner.Dispose(); } + } } } diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 9abbac6..92c1c00 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -452,6 +452,7 @@ + Form diff --git a/tests/DesktopProfileOperationHarness/DesktopProfileOperationHarness.csproj b/tests/DesktopProfileOperationHarness/DesktopProfileOperationHarness.csproj new file mode 100644 index 0000000..c177c6f --- /dev/null +++ b/tests/DesktopProfileOperationHarness/DesktopProfileOperationHarness.csproj @@ -0,0 +1,4 @@ + + Exenet8.0enableenable + + diff --git a/tests/DesktopProfileOperationHarness/Program.cs b/tests/DesktopProfileOperationHarness/Program.cs new file mode 100644 index 0000000..b73fdcf --- /dev/null +++ b/tests/DesktopProfileOperationHarness/Program.cs @@ -0,0 +1,108 @@ +using System.Reflection; +using Microsoft.Data.Sqlite; +using MySqlPunk.Core.Services; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using MySqlPunk.Core.Models; +using MySqlPunk.Desktop; + +var fixtureRoot = Environment.GetEnvironmentVariable("MYSQLPUNK_PROFILE_TEST_ROOT") ?? throw new InvalidOperationException("Use Run-DesktopProfileOperationTests.sh for isolated configuration."); +var store = new ConnectionProfileStore(); +if (!Path.GetFullPath(store.FilePath).StartsWith(Path.GetFullPath(fixtureRoot) + Path.DirectorySeparatorChar, StringComparison.Ordinal) || File.Exists(store.FilePath)) + throw new InvalidOperationException("Refusing to access non-isolated or existing profile settings."); +var databasePath = Path.Combine(fixtureRoot, "fixture.db"); +using (var database = new SqliteConnection("Data Source=" + databasePath)) +{ + database.Open(); + using var command = database.CreateCommand(); + command.CommandText = "CREATE TABLE fixture (id INTEGER PRIMARY KEY, label TEXT); INSERT INTO fixture VALUES (1, 'synthetic');"; + command.ExecuteNonQuery(); +} +await store.SaveAsync(new[] { new ConnectionProfile { Name = "Isolated profile operations", Provider = DatabaseProviderKind.Sqlite, Database = databasePath } }); + +return AppBuilder.Configure().UsePlatformDetect().WithInterFont().AfterSetup(builder => + Dispatcher.UIThread.Post(async () => + { + var lifetime = (IClassicDesktopStyleApplicationLifetime)Application.Current!.ApplicationLifetime!; + try + { + var window = (MainWindow)lifetime.MainWindow!; + var profiles = window.FindControl("ProfilesList")!; + await Wait(() => profiles.SelectedItem is ConnectionProfile); + Invoke(window, "ConnectProfile_Click", null!, null!); + await Wait(() => Field(window, "_session") != null && Field(window, "_operationCancellation") == null); + var session = Field(window, "_session"); + var selected = (ConnectionProfile)profiles.SelectedItem!; + var originalName = selected.Name; + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Func pendingSql = async token => { await barrier.Task; token.ThrowIfCancellationRequested(); }; + var pending = (Task)Invoke(window, "RunOperationAsync", "synthetic pending SQL", pendingSql)!; + foreach (var handler in new[] { "EditProfile_Click", "DeleteProfile_Click", "ConnectProfile_Click" }) + Invoke(window, handler, null!, null!); + Check(!window.OwnedWindows.Any() && ReferenceEquals(session, Field(window, "_session")), "Busy actual handlers reject edit/delete/connect without dialogs or disconnect"); + ((CancellationTokenSource)Field(window, "_operationCancellation")!).Cancel(); + Invoke(window, "EditProfile_Click", null!, null!); + Check(Field(window, "_operationCancellation") != null && !window.OwnedWindows.Any(), "Cancel retains real lease until pending operation completes"); + barrier.SetResult(); + await pending; + foreach (var handler in new[] { "EditProfile_Click", "DeleteProfile_Click" }) + { + Invoke(window, handler, null!, null!); + await Wait(() => window.OwnedWindows.Any()); + var dialog = window.OwnedWindows.Single(); + int changes = 0; + Func competing = token => { changes++; return Task.CompletedTask; }; + await (Task)Invoke(window, "RunOperationAsync", "must not execute", competing)!; + Check(changes == 0 && Field(window, "_operationCancellation") != null, "Actual " + handler + " dialog holds shared gate"); + ((CancellationTokenSource)Field(window, "_operationCancellation")!).Cancel(); + if (handler == "EditProfile_Click") + { + var edited = selected.Clone(); edited.Name = "must-not-save"; + dialog.Close(edited); + } + else dialog.Close(true); + await Wait(() => Field(window, "_operationCancellation") == null); + Check(ReferenceEquals(session, Field(window, "_session")) && ((ConnectionProfile)profiles.SelectedItem!).Name == originalName, "Actual " + handler + " post-await cancellation preserves profile and live isolated session"); + } + Invoke(window, "EditProfile_Click", null!, null!); + await Wait(() => window.OwnedWindows.Any()); + var saved = selected.Clone(); saved.Name = "Isolated edited profile"; + window.OwnedWindows.Single().Close(saved); + await Wait(() => Field(window, "_operationCancellation") == null); + Check(((ConnectionProfile)profiles.SelectedItem!).Name == saved.Name && Field(window, "_session") == null, "Idle edit still saves and disconnects under the lease"); + Invoke(window, "ConnectProfile_Click", null!, null!); + await Wait(() => Field(window, "_session") != null && Field(window, "_operationCancellation") == null); + Invoke(window, "DeleteProfile_Click", null!, null!); + await Wait(() => window.OwnedWindows.Any()); + window.OwnedWindows.Single().Close(true); + await Wait(() => Field(window, "_operationCancellation") == null); + Check(profiles.SelectedItem == null && Field(window, "_session") == null && (await store.LoadAsync()).Count == 0, "Idle delete still removes only the isolated profile and disconnects"); + Check(File.Exists(databasePath), "Profile deletion does not delete the isolated database"); + Console.WriteLine("PASS actual Avalonia edit/delete/connect handlers, busy entry, dialog lease, cancellation and ownership"); + lifetime.Shutdown(0); + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + lifetime.Shutdown(1); + } + })).StartWithClassicDesktopLifetime(args); + +static object? Field(object target, string name) => target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target); +static object? Invoke(object target, string name, params object[] values) => target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(target, values); +static void Check(bool success, string description) +{ + if (!success) throw new Exception(description); + Console.WriteLine("PASS " + description); +} +static async Task Wait(Func predicate) +{ + for (int attempt = 0; attempt < 100; attempt++) + { + if (predicate()) return; + await Task.Delay(50); + } + throw new TimeoutException("isolated UI step did not finish"); +} diff --git a/tests/Run-DesktopProfileOperationTests.sh b/tests/Run-DesktopProfileOperationTests.sh new file mode 100755 index 0000000..963d5a0 --- /dev/null +++ b/tests/Run-DesktopProfileOperationTests.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +project="$repo_root/tests/DesktopProfileOperationHarness/DesktopProfileOperationHarness.csproj" +command -v xvfb-run >/dev/null +dotnet build "$project" -c Release +fixture_root=$(mktemp -d "${TMPDIR:-/tmp}/mysqlpunk-profile-operations.XXXXXX") +trap 'rm -rf -- "$fixture_root"' EXIT +mkdir -p "$fixture_root/home" "$fixture_root/config" "$fixture_root/data" "$fixture_root/cache" +HOME="$fixture_root/home" XDG_CONFIG_HOME="$fixture_root/config" XDG_DATA_HOME="$fixture_root/data" XDG_CACHE_HOME="$fixture_root/cache" \ + MYSQLPUNK_PROFILE_TEST_ROOT="$fixture_root" timeout 60s xvfb-run -a \ + dotnet "$repo_root/tests/DesktopProfileOperationHarness/bin/Release/net8.0/DesktopProfileOperationHarness.dll" diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index 1066dbb..406dc2f 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -146,6 +146,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { (Join-Path $PSScriptRoot "SqlServerBatchInsertTests.cs") ` (Join-Path $PSScriptRoot "SqlServerRestoreCompatibilityTests.cs") ` (Join-Path $PSScriptRoot "SqlServerMigrationTests.cs") ` + (Join-Path $PSScriptRoot "SqlServerMigrationCatalogTests.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.MssqlExport.cs b/tests/SmokeTests.MssqlExport.cs index 3902cb7..a3205ab 100644 --- a/tests/SmokeTests.MssqlExport.cs +++ b/tests/SmokeTests.MssqlExport.cs @@ -186,6 +186,7 @@ private sealed class MetadataCommand : System.Data.Common.DbCommand private static void Session_DisposesOwnerOnce() { + MigrationSession_FactoryOwnership(); var owner = new MssqlSessionOwner(); using (var connection = new SqlConnection()) { @@ -204,6 +205,46 @@ private static void Session_DisposesOwnerOnce() AssertThrows(() => ConnectionOpenService.OpenMssqlSession(new Dictionary { { "db_kind", "sqlite" } }), "Other provider rejected before connection"); } + private static void MigrationSession_FactoryOwnership() + { + var settings = new Dictionary { + { "db_kind", "mssql" }, { "pwd", "synthetic-fixture-only" }, { "ssh_enabled", true }, + { "initial_database", "original" }, { "connString", "must-not-be-used" }, { "pdo", new object() } + }; + foreach (string selected in new[] { "source_fixture", "target_fixture" }) + { + var provider = new MssqlSessionOwner(); + using (var connection = new SqlConnection()) + { + var owned = new MssqlOwnedSession(provider, connection); + using (var migration = new SqlServerMigrationSession(settings, selected, copied => { + Assert((string)copied["pwd"] == "synthetic-fixture-only", "SQL auth uses runtime settings, not redacted ConnectionString"); + Assert((bool)copied["ssh_enabled"] && (string)copied["initial_database"] == selected, "Selected endpoint and SSH settings reach factory"); + Assert(!copied.ContainsKey("pdo") && !copied.ContainsKey("connString"), "Live provider and cached connection string excluded"); + return owned; + })) + { + Assert(provider.DisposeCount == 0, "Provider/SSH owner retained while migration runs"); + migration.Dispose(); + } + Assert(provider.DisposeCount == 1, "Migration disposes provider/SSH owner once"); + } + AssertThrows(() => new SqlServerMigrationSession(settings, selected, copied => { + throw new InvalidOperationException("synthetic factory failure"); + }), "Both endpoints propagate factory failures"); + } + var sourceOwner = new MssqlSessionOwner(); + using (var connection = new SqlConnection()) + { + AssertThrows(() => { + using (var source = new SqlServerMigrationSession(settings, "source_fixture", copied => new MssqlOwnedSession(sourceOwner, connection))) + using (var target = new SqlServerMigrationSession(settings, "target_fixture", copied => { throw new InvalidOperationException(); })) { } + }, "Target open failure unwinds source ownership"); + } + Assert(sourceOwner.DisposeCount == 1, "Source owner released when target factory fails"); + Assert((string)settings["initial_database"] == "original", "Factory does not mutate stored endpoint settings"); + } + private static void Request_CopiesSelectionAndRejectsInvalidMode() { var tables = new List { new MssqlObjectKey(7, "dbo", "Orders") }; diff --git a/tests/SmokeTests.SchemaSync.cs b/tests/SmokeTests.SchemaSync.cs index 52e352a..9ffbabb 100644 --- a/tests/SmokeTests.SchemaSync.cs +++ b/tests/SmokeTests.SchemaSync.cs @@ -502,6 +502,17 @@ public static void AssertAutomationSemantics(Func openSqlite, /// public static void AssertDataTransferSemantics(IDatabase source, IDatabase target, string checkpointDirectory) { + foreach (bool? verified in new bool?[] { null, false, true }) + foreach (bool blocked in new[] { false, true }) + { + var legacy = new TransferItem { Status = TransferItemStatus.Done, Verified = verified, RetryBlocked = blocked }; + bool complete = verified == true && !blocked; + Assert(legacy.IsComplete == complete && legacy.NeedsProcessing != complete, "Legacy checkpoint UI/service predicate agrees on verification and retry blocking."); + var legacyPlan = new TransferPlan(); legacyPlan.Items.Add(legacy); + Assert(legacyPlan.IsFinished == complete, "Legacy Done is finished only when verified and not blocked."); + legacy.Include = false; + Assert(!legacy.NeedsProcessing && legacyPlan.IsFinished, "Excluded items are not resume candidates."); + } Action exec = (db, sql) => AssertEquals("OK", db.ExecSQL(sql)["status"], "Transfer fixture: " + sql); exec(source, "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT)"); exec(source, "CREATE TABLE logs (msg TEXT)"); diff --git a/tests/SmokeTests.cs b/tests/SmokeTests.cs index 9dfa096..e26956e 100644 --- a/tests/SmokeTests.cs +++ b/tests/SmokeTests.cs @@ -13221,6 +13221,7 @@ private static void TestDataTransfer() } SqlServerRestoreCompatibilityTests.Run(); SqlServerMigrationTests.Run(); + SqlServerMigrationCatalogTests.Run(); string dir = Path.Combine(Path.GetTempPath(), "mysqlpunk-transfer-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try @@ -13249,6 +13250,29 @@ private static void TestDataTransfer() AssertEquals("2", target.CountRows("main", "extra_copy").ToString(), "The copy should contain every row."); AssertContains(form.BuildReport(), "extra_copy", "The report should list the transferred table."); } + foreach (bool? verified in new bool?[] { null, false, true }) + foreach (bool blocked in new[] { false, true }) + { + string checkpoints = Path.Combine(dir, "legacy-" + (verified.HasValue ? verified.Value.ToString() : "null") + "-" + blocked); + var legacy = DataTransferService.BuildPlan(source, "main", "Source", target, "main", "Target", new[] { "extra" }); + var item = legacy.Items[0]; item.Mode = TransferMode.CreateNew; item.TargetTable = "extra_copy"; item.CopiedRows = 2; + item.Status = TransferItemStatus.Done; item.Verified = verified; item.RetryBlocked = blocked; + DataTransferService.SaveCheckpoint(legacy, checkpoints); + using (var form = new DataTransferForm(from, to, checkpoints)) + { + form.CreateControl(); form.LoadTables(); + bool complete = verified == true && !blocked; + Assert(form.ResumeAvailable != complete, "Legacy checkpoint resume availability agrees with service completion."); + if (!complete) + { + form.UseUnfinished(); + Assert(form.Plan.Items.Count(candidate => candidate.NeedsProcessing) == 1, "Legacy unverified/blocked Done remains in UI execution selection."); + form.Execute(System.Threading.CancellationToken.None); + Assert(form.Plan.IsFinished == (verified == null && !blocked), "Legacy null verification can be rechecked; failed or uncertain transfer stays blocked."); + AssertEquals("2", target.CountRows("main", "extra_copy").ToString(), "Legacy checkpoint never duplicates target rows."); + } + } + } } } finally diff --git a/tests/SqlServerMigrationCatalogTests.cs b/tests/SqlServerMigrationCatalogTests.cs new file mode 100644 index 0000000..b6d3347 --- /dev/null +++ b/tests/SqlServerMigrationCatalogTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Data; +using System.Linq; +using mySQLPunk.lib; + +public static class SqlServerMigrationCatalogTests +{ + public static void Run() + { + foreach (string endpoint in new[] { "source", "target" }) + foreach (bool disabled in new[] { false, true }) + { + bool queried = false; + var catalog = SqlServerMigrationCatalogReader.Read(sql => { + if (sql.Contains("sys.triggers")) + { + Check(sql.Contains("parent_class=0") && !sql.Contains("is_disabled=0"), "Database scope includes disabled triggers"); + queried = true; + var triggers = new DataTable(); triggers.Columns.Add("name"); triggers.Columns.Add("is_disabled", typeof(bool)); + triggers.Rows.Add("fixture_ddl_" + endpoint, disabled); return triggers; + } + return Fixture(sql, endpoint); + }); + Check(queried && catalog.Blockers.Any(reason => reason.Contains("fixture_ddl_" + endpoint)), "Real catalog query blocks " + endpoint + " DDL trigger"); + var source = SqlServerMigrationCatalogReader.Read(sql => Fixture(sql, "source")); + var target = SqlServerMigrationCatalogReader.Read(sql => Fixture(sql, "target")); + if (endpoint == "source") source = catalog; else target = catalog; + var preview = SqlServerCompatibilityMigration.Preview(source, target, 15); + Check(!preview.CanStart && preview.Blockers.Any(reason => reason.Contains("fixture_ddl_" + endpoint)), "Preview preserves endpoint trigger blocker"); + } + foreach (string endpoint in new[] { "source", "target" }) + { + bool failureClosed = false; + try + { + SqlServerMigrationCatalogReader.Read(sql => { + if (sql.Contains("sys.triggers")) throw new InvalidOperationException("synthetic metadata denied"); + return Fixture(sql, endpoint); + }); + } + catch (InvalidOperationException) { failureClosed = true; } + Check(failureClosed, "Unreadable " + endpoint + " trigger catalog never produces an executable preview"); + } + var empty = SqlServerMigrationCatalogReader.Read(sql => Fixture(sql, "target")); + Check(empty.Blockers.Count == 0, "Ordinary empty target without DDL triggers remains usable"); + var ordinary = SqlServerMigrationCatalogReader.Read(sql => Fixture(sql, "source")); + Check(SqlServerCompatibilityMigration.Preview(ordinary, empty, 15).CanStart, "Same catalog fixture passes without database triggers"); + } + + private static DataTable Fixture(string sql, string endpoint) + { + var result = new DataTable(); + if (sql.Contains("SERVERPROPERTY")) + { + foreach (string column in new[] { "server_name", "version", "db_name", "db_id", "create_date", "collation_name" }) result.Columns.Add(column); + result.Rows.Add("isolated", endpoint == "source" ? "17.0.1.0" : "15.0.1.0", endpoint, endpoint == "source" ? "5" : "6", "2026-01-01", "same"); + } + else if (sql.Contains("HAS_PERMS_BY_NAME")) + { + result.Columns.Add("visible"); result.Columns.Add("controlled"); result.Rows.Add("1", "1"); + } + else if (endpoint == "source" && sql.Contains("FROM sys.objects")) + { + result.Columns.Add("type"); result.Columns.Add("name"); result.Rows.Add("U", "fixture_rows"); + } + else if (endpoint == "source" && sql.Contains("FROM sys.tables t")) + { + result.Columns.Add("object_id", typeof(int)); result.Columns.Add("name"); result.Columns.Add("schema_name"); + foreach (string column in new[] { "is_memory_optimized", "is_filetable", "is_tracked_by_cdc", "is_replicated", "is_merge_published", "is_sync_tran_subscribed", "is_remote_data_archive_enabled" }) result.Columns.Add(column, typeof(bool)); + foreach (string column in new[] { "temporal_type", "indexes", "compressed", "security_predicates", "storage_features" }) result.Columns.Add(column, typeof(int)); + var row = result.NewRow(); row["object_id"] = 101; row["name"] = "fixture_rows"; row["schema_name"] = "dbo"; + foreach (DataColumn column in result.Columns) + { + if (column.DataType == typeof(bool)) row[column] = false; + if (column.DataType == typeof(int) && column.ColumnName != "object_id") row[column] = 0; + } + result.Rows.Add(row); + } + else if (endpoint == "source" && sql.Contains("FROM sys.columns")) + { + foreach (string column in new[] { "name", "type_name", "collation_name", "encryption_type" }) result.Columns.Add(column); + foreach (string column in new[] { "max_length", "precision", "scale", "generated_always_type" }) result.Columns.Add(column, typeof(int)); + foreach (string column in new[] { "is_nullable", "is_identity", "is_computed", "is_sparse", "is_column_set", "is_rowguidcol", "is_ansi_padded", "is_filestream", "is_hidden", "is_masked" }) result.Columns.Add(column, typeof(bool)); + var row = result.NewRow(); row["name"] = "id"; row["type_name"] = "int"; + foreach (DataColumn column in result.Columns) + { + if (column.DataType == typeof(bool)) row[column] = column.ColumnName == "is_ansi_padded"; + if (column.DataType == typeof(int)) row[column] = 0; + } + row["max_length"] = 4; row["precision"] = 10; result.Rows.Add(row); + } + else if (endpoint == "source" && sql.Contains("COUNT_BIG")) + { + result.Columns.Add("count", typeof(long)); result.Rows.Add(5L); + } + return result; + } + + private static void Check(bool value, string message) + { + if (!value) throw new Exception(message); + } +}