Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,取消後等待舊操作結束才接受新操作,取消結果不再覆蓋畫面。
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tar.gz>`,確認隔離安裝、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

Expand Down
3 changes: 2 additions & 1 deletion docs/MSSQL_COMPATIBILITY_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 欄位或查詢錯誤也停止。
Expand Down
39 changes: 39 additions & 0 deletions mySQLPunk.CrossPlatform.SmokeTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<bool>(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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<Compile Include="../mySQLPunk/lib/SqlServerRestoreCompatibility.cs" Link="SqlServerRestoreCompatibility.cs" />
<Compile Include="../tests/SqlServerRestoreCompatibilityTests.cs" Link="SqlServerRestoreCompatibilityTests.cs" />
<Compile Include="../tests/SqlServerMigrationTests.cs" Link="SqlServerMigrationTests.cs" />
<Compile Include="../mySQLPunk/lib/SqlServerMigrationCatalogReader.cs" Link="SqlServerMigrationCatalogReader.cs" />
<Compile Include="../tests/SqlServerMigrationCatalogTests.cs" Link="SqlServerMigrationCatalogTests.cs" />
<ProjectReference Include="../mySQLPunk.Core/mySQLPunk.Core.csproj" />
</ItemGroup>

Expand Down
129 changes: 73 additions & 56 deletions mySQLPunk.Desktop/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionProfile?>(this);
if (result is null)
{
return;
}
var editor = new ConnectionEditorWindow(editable, passwordResolution.Warning, _secretStore);
var result = await editor.ShowDialog<ConnectionProfile?>(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();
}
Expand Down Expand Up @@ -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<IDatabaseSession>(DatabaseProviderFactory.Create(connectionProfile));
var session = lease.Session;
Expand Down
4 changes: 2 additions & 2 deletions mySQLPunk/DataTransferForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -221,7 +221,7 @@ private async void StartAsync(bool resume)
{
if (running || plan == null) return;
if (resume) UseUnfinished();
List<TransferItem> included = plan.Items.Where(item => item.Include && item.Status != TransferItemStatus.Done).ToList();
List<TransferItem> included = plan.Items.Where(item => item.NeedsProcessing).ToList();
if (included.Count == 0)
{
statusLabel.Text = Localization.T("Transfer.NothingSelected");
Expand Down
6 changes: 4 additions & 2 deletions mySQLPunk/Form1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand All @@ -14990,7 +14991,8 @@ private List<SchemaComparisonEndpoint> GetOpenSchemaComparisonEndpoints()
ConnectionName = GetConnectionValue(connectionInfo, "conn_name"),
DatabaseName = databaseNode.Text,
ProviderName = database.ProviderName,
Database = database
Database = database,
ConnectionSettings = connectionInfo
});
}
}
Expand Down
1 change: 1 addition & 0 deletions mySQLPunk/SchemaComparisonForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> ConnectionSettings { get; set; }

public string DisplayName
{
Expand Down
2 changes: 1 addition & 1 deletion mySQLPunk/SqlServerMigrationForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading