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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
- **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。

### 🛠️ 問題修正與優化
- SQLite SQL 與執行計畫改在背景執行,長查詢透過原生 progress handler 中斷,不再凍結 UI;取消後等實際語句停止才釋放操作鎖。取消訊息明示先前已提交語句不會自動回滾,避免盲目重試。

- SQL Server 相容搬移改用既有連線設定工廠,正常 SQL authentication 不再受已開啟連線隱藏密碼影響,SSH tunnel 與 provider owner 一起釋放。
- 桌面編輯、刪除與準備連線全程持有操作 gate,等待密碼或確認對話框時也不允許 SQL 重入;取消後不再更新設定或斷開現有 session。
Expand Down
6 changes: 6 additions & 0 deletions docs/FEATURE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,9 @@
## SQL Server 搬移安全性

新增受限的 SQL Server 邏輯降版預檢與 Windows 確認介面;來源/目標版本、空目標、定序、feature 白名單、全交易及內容驗證的詳細範圍與未驗證矩陣,見 [MSSQL_COMPATIBILITY_MIGRATION.md](MSSQL_COMPATIBILITY_MIGRATION.md)。原生 RESTORE 的版本 guard 不等於完整邏輯降版;一般資料傳輸的列數驗證也不等於內容驗證。
# SQLite SQL 背景執行與取消

`ExecuteAsync` 與 `ExplainAsync` 在背景工作執行,因 Microsoft.Data.Sqlite 的 async ADO 方法實際同步運作。每個查詢使用獨立連線;在讀取結果期間安裝 SQLitePCL 原生 progress handler,每 1,000 VM 指令檢查 token,取消時回傳非零使 SQLite 停止。callback 只在工作執行緒讀取 token,不從取消執行緒碰 native handle;reader 關閉後先解除 callback,才釋放連線,沒有跨連線中斷或 registration/dispose 競態。

桌面 gate 等完整 task 停止才釋放,取消結果不寫回 grid。隔離 smoke 實際執行 50,000,000 次 recursive 計算,驗證快速返回、三次取消與後續查詢、一般 SQL 錯誤及已提交 DML 不回滾。Xvfb harness 使用正式 SQL/Cancel handlers 驗證 UI tick、重複點擊、busy edit/delete/connect、取消後查詢。這不是資料同步或所有 metadata 路徑的背景化保證;鎖等待仍受 provider timeout 限制。

6 changes: 3 additions & 3 deletions mySQLPunk.Core/Providers/AdoDatabaseSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public abstract Task<IReadOnlyList<DatabaseObjectInfo>> GetObjectsAsync(
string database,
CancellationToken cancellationToken = default);

public async Task<QueryResult> ExecuteAsync(
public virtual async Task<QueryResult> ExecuteAsync(
string database,
string sql,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -437,7 +437,7 @@ public async Task<StatementBatchResult> ExecuteBatchAsync(
return new StatementBatchResult(useTransaction, results);
}

public async Task<QueryPlanDocument> ExplainAsync(
public virtual async Task<QueryPlanDocument> ExplainAsync(
string database,
string sql,
CancellationToken cancellationToken = default)
Expand All @@ -458,7 +458,7 @@ protected virtual Task<QueryResult> ReadPlanRowsAsync(
CancellationToken cancellationToken) =>
ReadResultAsync(connection, explainSql, cancellationToken);

protected async Task<QueryResult> ReadResultAsync(
protected virtual async Task<QueryResult> ReadResultAsync(
DbConnection connection,
string sql,
CancellationToken cancellationToken)
Expand Down
29 changes: 29 additions & 0 deletions mySQLPunk.Core/Providers/SqliteDatabaseSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,35 @@ public SqliteDatabaseSession(ConnectionProfile profile)
{
}

public override Task<QueryResult> ExecuteAsync(string database, string sql, CancellationToken cancellationToken = default) =>
Task.Run(() => base.ExecuteAsync(database, sql, cancellationToken), cancellationToken);

public override Task<QueryPlanDocument> ExplainAsync(string database, string sql, CancellationToken cancellationToken = default) =>
Task.Run(() => base.ExplainAsync(database, sql, cancellationToken), cancellationToken);

protected override async Task<QueryResult> ReadResultAsync(
System.Data.Common.DbConnection connection, string sql, CancellationToken cancellationToken)
{
var handle = ((SqliteConnection)connection).Handle!;
SQLitePCL.raw.sqlite3_progress_handler(handle, 1000,
_ => cancellationToken.IsCancellationRequested ? 1 : 0, null);
try
{
cancellationToken.ThrowIfCancellationRequested();
var result = await base.ReadResultAsync(connection, sql, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return result;
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 9 && cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException("SQLite 已中止目前語句;先前自動提交的語句不會因此回滾。", exception, cancellationToken);
}
finally
{
SQLitePCL.raw.sqlite3_progress_handler(handle, 0, null, null);
}
}

protected override SqliteConnection CreateConnection(string? database)
{
var dataSource = string.IsNullOrWhiteSpace(database) ? Profile.Database : database;
Expand Down
59 changes: 59 additions & 0 deletions mySQLPunk.CrossPlatform.SmokeTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
("Linux Secret Service 安全 round-trip", LinuxSecretServiceRoundTripAsync),
("macOS Keychain 安全 round-trip", MacOsKeychainRoundTripAsync),
("SQLite 查詢與 DDL/DML", SqliteExecutesQueriesAsync),
("SQLite 背景查詢與原生取消", SqliteResponsiveCancellationAsync),
("執行計畫解析與安全規則", QueryPlanParsingAsync),
("資料字典結構與 HTML 匯出", DataDictionaryAsync),
("結構比較差異與報告", SchemaComparisonAsync),
Expand Down Expand Up @@ -2197,6 +2198,64 @@ exit 2
}
}

static async Task SqliteResponsiveCancellationAsync()
{
var directory = CreateTemporaryDirectory();
try
{
var profile = CreateSqliteProfile(Path.Combine(directory, "responsive.db"));
using var session = DatabaseProviderFactory.Create(profile);
await session.ExecuteAsync(profile.Database, "CREATE TABLE committed (id INTEGER); INSERT INTO committed VALUES (1);");
const string calculation = "WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 50000000) SELECT sum(n) FROM numbers;";
for (var attempt = 0; attempt < 3; attempt++)
{
using var cancellation = new CancellationTokenSource();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var pending = session.ExecuteAsync(profile.Database, calculation, cancellation.Token);
Assert(stopwatch.Elapsed < TimeSpan.FromMilliseconds(250) && !pending.IsCompleted,
"呼叫不能同步執行整個 SQLite 計算");
await Task.Delay(50);
cancellation.Cancel();
try
{
await pending.WaitAsync(TimeSpan.FromSeconds(3));
throw new InvalidOperationException("長計算應中斷");
}
catch (OperationCanceledException)
{
Assert(pending.IsCompleted && stopwatch.Elapsed < TimeSpan.FromSeconds(3), "原生計算必須已停止,不只是放棄等待");
}
var next = await session.ExecuteAsync(profile.Database, "SELECT 42;");
Assert(Convert.ToInt64(next.Rows.Single().Single()) == 42, "取消後下一查詢仍成功");
}
using var writeCancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
try
{
await session.ExecuteAsync(profile.Database, "INSERT INTO committed VALUES (2); " + calculation, writeCancellation.Token)
.WaitAsync(TimeSpan.FromSeconds(3));
throw new InvalidOperationException("複合語句應取消");
}
catch (OperationCanceledException) { }
var committed = await session.ExecuteAsync(profile.Database, "SELECT count(*) FROM committed;");
Assert(Convert.ToInt64(committed.Rows.Single().Single()) == 2, "取消不能宣稱先前 autocommit INSERT 已回滾");
using var alreadyCancelled = new CancellationTokenSource();
alreadyCancelled.Cancel();
try
{
await session.ExecuteAsync(profile.Database, "INSERT INTO committed VALUES (3);", alreadyCancelled.Token);
throw new InvalidOperationException("預先取消不應寫入");
}
catch (OperationCanceledException) { }
try
{
await session.ExecuteAsync(profile.Database, "SELECT * FROM missing_table;");
throw new InvalidOperationException("一般 SQL 錯誤不應被吞掉");
}
catch (Microsoft.Data.Sqlite.SqliteException) { }
}
finally { Directory.Delete(directory, true); }
}

static async Task SqliteExecutesQueriesAsync()
{
var directory = CreateTemporaryDirectory();
Expand Down
2 changes: 1 addition & 1 deletion mySQLPunk.Desktop/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,7 @@ private async Task RunOperationAsync(string status, Func<CancellationToken, Task
}
catch (OperationCanceledException)
{
SetStatus("操作已取消。");
SetStatus("操作已取消;已提交的 SQL 不會自動回滾,請先確認資料再重試。");
}
catch (Exception exception)
{
Expand Down
24 changes: 18 additions & 6 deletions tests/DesktopProfileOperationHarness/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,29 @@
var session = Field(window, "_session");
var selected = (ConnectionProfile)profiles.SelectedItem!;
var originalName = selected.Name;
var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Func<CancellationToken, Task> pendingSql = async token => { await barrier.Task; token.ThrowIfCancellationRequested(); };
var pending = (Task)Invoke(window, "RunOperationAsync", "synthetic pending SQL", pendingSql)!;
const string calculation = "WITH RECURSIVE numbers(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 50000000) SELECT sum(n) FROM numbers;";
window.FindControl<TextBox>("SqlEditor")!.Text = calculation;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var pending = (Task)Invoke(window, "ExecuteCurrentSqlAsync", false)!;
Check(stopwatch.Elapsed < TimeSpan.FromMilliseconds(250) && !pending.IsCompleted, "Actual SQL handler returns promptly during real SQLite calculation");
bool ticked = false;
Dispatcher.UIThread.Post(() => ticked = true);
await Task.Delay(50);
Check(ticked && !pending.IsCompleted, "Single UI thread keeps ticking while SQLite is executing");
var activeCancellation = Field(window, "_operationCancellation");
Invoke(window, "ExecuteSql_Click", null!, null!);
Check(ReferenceEquals(activeCancellation, Field(window, "_operationCancellation")), "Repeated actual SQL click retains original operation");
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, "CancelOperation_Click", null!, null!);
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;
await pending.WaitAsync(TimeSpan.FromSeconds(3));
Check(stopwatch.Elapsed < TimeSpan.FromSeconds(3) && Field(window, "_operationCancellation") == null, "Actual Cancel stops SQLite before releasing operation gate");
window.FindControl<TextBox>("SqlEditor")!.Text = "SELECT 42;";
await (Task)Invoke(window, "ExecuteCurrentSqlAsync", false)!;
Check(!window.OwnedWindows.Any() && ReferenceEquals(session, Field(window, "_session")), "Query after native interruption succeeds without disconnecting");
foreach (var handler in new[] { "EditProfile_Click", "DeleteProfile_Click" })
{
Invoke(window, handler, null!, null!);
Expand Down
Loading