Skip to content

Commit dc24f2e

Browse files
NickYCLinclaude
andcommitted
fix(cross-platform): 保留 MySQL 索引前綴長度與降冪
結構讀取改帶 information_schema.STATISTICS 的 SUB_PART 與 COLLATION, 索引欄位呈現為 code(8)、id DESC,資料字典、結構比較與同步 SQL 不再把 前綴索引當成整欄索引。同步腳本會正確引號化 `code`(8),函式索引(運算式 無法從 information_schema 取得)改列手動處理。實機矩陣在 MySQL 8 與 MariaDB 11.4 以前綴+降冪索引跑產生→套用→重比往返。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CM3dbt782c4iCA1TJyn2p8
1 parent fd420bb commit dc24f2e

4 files changed

Lines changed: 37 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
- **Linux/macOS 連線 URI 與完整 TLS 模式**:新增/編輯連線時可貼上 MySQL/MariaDB、PostgreSQL、SQL Server 或 SQLite URI,自動套用 provider、主機、port、認證、資料庫、逾時及 provider 原生的 Prefer/Require/VerifyCA/VerifyFull/Mandatory/Strict 等 TLS 語意;舊 `useSsl` 會依 provider 遷移成等價模式。URI 輸入會遮蔽並在成功後立即清除,未知/重複參數、未編碼空白、dot-segment、Unicode 控制/noncharacter、過長內容與不相容模式一律 fail closed,錯誤訊息不回顯密碼,`connections.json` 仍不保存密碼。
1313
- **Linux/macOS TLS 憑證檔案**:跨平台連線設定頁新增 CA 憑證(SQL Server 為伺服器憑證)、客戶端憑證與客戶端私鑰路徑,分別對應 MySqlConnector `SslCa`/`SslCert`/`SslKey`、Npgsql `RootCertificate`/`SslCertificate`/`SslKey` 與 SqlClient `ServerCertificate`,URI 也可用 `sslca`、`sslrootcert`、`sslcert`、`sslkey`、`servercertificate` 一併帶入。所有規則 fail closed:CA 憑證只接受 VerifyCA/VerifyFull(SQL Server 為 Mandatory/Strict),客戶端憑證與私鑰須成對、模式至少 Required,SQL Server 與 SQLite 不接受客戶端憑證;路徑必須是絕對路徑且不含控制字元,儲存、測試與連線前會確認檔案存在、不是目錄,且私鑰在 Unix 上不可被其他使用者讀取。切換資料庫類型時不支援的客戶端憑證欄位會清空,`connections.json` 以明確欄位保存路徑、仍不保存密碼或私鑰內容。實機矩陣會以 MySQL 8 容器自動產生的 CA 驗證 VerifyCA 通過、VerifyFull(主機名稱不符)與錯誤 CA 失敗,並以臨時 CA 簽發 SAN 127.0.0.1 憑證的 PostgreSQL 16 容器驗證 VerifyFull 成功、Disabled 不加密與錯誤 CA 失敗;SQL Server 2022 TLS 容器則驗證 Mandatory 搭配伺服器憑證精確比對成功、未固定或錯誤憑證失敗。
1414
- **Linux/macOS 執行計畫**:跨平台查詢工具列新增「執行計畫」,依游標所在 statement 或反白範圍取得 MySQL/MariaDB `EXPLAIN FORMAT=JSON`、PostgreSQL `EXPLAIN (FORMAT JSON, ANALYZE FALSE)`、SQL Server `SHOWPLAN_ALL` 與 SQLite `EXPLAIN QUERY PLAN` 的唯讀計畫,以獨立視窗呈現樹狀節點(相對成本 ≥ 50% 標紅、≥ 20% 標橘)、節點屬性、文字計畫與原始 JSON/表格,可一鍵複製文字計畫。一次只接受一個 SELECT/WITH/INSERT/UPDATE/DELETE/REPLACE,拒絕 DDL 與多 statement,分隔符判斷不受字串、識別字與註解內分號影響;不使用 ANALYZE、SQL Server 在同一連線開關 SHOWPLAN,失敗也一定關閉,因此解釋 INSERT/DELETE 不會修改資料。SQLite 對簡單 INSERT 回傳零列時以「無計畫步驟」呈現。smoke test 覆蓋四種 provider 的解析、深度上限、循環 parent 與拒絕案例,SQLite 與 Docker 實機矩陣都驗證解釋 INSERT/DELETE 後列數不變、被拒絕的請求不執行任何 statement,且 SQL Server 之後的查詢仍回傳資料。
15-
- **Linux/macOS 同步 SQL 預覽**:結構比較視窗新增「同步 SQL 預覽…」,同類型資料庫之間可產生讓目標結構跟上來源的指令碼:建立資料表(MySQL 取 SHOW CREATE TABLE、SQLite 取原始建立語法、PostgreSQL/SQL Server 依欄位與主鍵組出)、新增與修改欄位、建立或重建索引、新增或重建外鍵、重建檢視表,依建表→欄位→索引→外鍵→檢視表排序,外鍵延後建立避免參照表尚未存在。會刪除資料或物件的變更(只在目標的資料表、欄位、索引、外鍵、檢視表)一律以註解輸出、預設不執行;型別收窄、SQL Server 具名預設值、主鍵變更、identity/generated 差異、SQLite 無法 ALTER 的欄位與外鍵等列在「需手動處理」。視窗只提供複製與另存 .sql,不送進編輯器,因為編輯器連的是來源;跨類型比較不產生腳本。所有註解文字都壓成單行,惡意物件名稱無法逃出註解。smoke test 與 Docker 實機矩陣在 SQLite、MySQL 8、MariaDB 11.4、PostgreSQL 16、SQL Server 2022 上執行「產生→套用→重新比較」,確認只剩被註解的破壞性差異。
15+
- **Linux/macOS 同步 SQL 預覽**:結構比較視窗新增「同步 SQL 預覽…」,同類型資料庫之間可產生讓目標結構跟上來源的指令碼:建立資料表(MySQL 取 SHOW CREATE TABLE、SQLite 取原始建立語法、PostgreSQL/SQL Server 依欄位與主鍵組出)、新增與修改欄位、建立或重建索引(MySQL/MariaDB 保留前綴長度與降冪,函式索引列為手動)、新增或重建外鍵、重建檢視表,依建表→欄位→索引→外鍵→檢視表排序,外鍵延後建立避免參照表尚未存在。會刪除資料或物件的變更(只在目標的資料表、欄位、索引、外鍵、檢視表)一律以註解輸出、預設不執行;型別收窄、SQL Server 具名預設值、主鍵變更、identity/generated 差異、SQLite 無法 ALTER 的欄位與外鍵等列在「需手動處理」。視窗只提供複製與另存 .sql,不送進編輯器,因為編輯器連的是來源;跨類型比較不產生腳本。所有註解文字都壓成單行,惡意物件名稱無法逃出註解。smoke test 與 Docker 實機矩陣在 SQLite、MySQL 8、MariaDB 11.4、PostgreSQL 16、SQL Server 2022 上執行「產生→套用→重新比較」,確認只剩被註解的破壞性差異。
1616
- **Linux/macOS 結構比較**:物件面板新增「結構比較…」,可把目前資料庫與任一已儲存連線(同連線的其他資料庫或不同 provider)的資料庫做唯讀結構比對,列出只在來源/只在目標的資料表與檢視表,以及欄位(型別、NULL、PK、預設值、identity/generated)、索引(唯一性與欄位順序)、外鍵(參照與 ON UPDATE/DELETE)的差異,可依狀態篩選並匯出附 CSP 的 HTML 報告。物件先以 schema+名稱、再以唯一名稱對應,所以 dbo.orders 與 public.orders 能跨 provider 對上;主鍵依角色比對、外鍵依欄位清單比對,不因自動命名不同而誤報,型別大小寫與 SQL Server 預設值外層括號會正規化。無法讀取的物件列為警告、不宣稱一致;只讀 metadata,不產生或執行任何同步 SQL。目標連線沿用主視窗的密碼庫/SSH 祕密解析,缺密碼時跳出連線設定,視窗關閉即釋放目標 session。
1717
- **Linux/macOS 資料字典**:跨平台主視窗的資料庫物件面板新增「資料字典…」,可把目前資料庫的所有資料表與檢視表匯出成單一 HTML:欄位(型別、NULL、PK、預設值、identity/generated/computed、定序、註解)、索引(唯一、PK、類型、欄位順序與方向、INCLUDE/定義)、外鍵(欄位、參照、ON UPDATE/DELETE)、資料表註解與建立語法或檢視表定義;MySQL/MariaDB 以 information_schema+SHOW CREATE TABLE、PostgreSQL 以 pg_catalog、SQL Server 以 sys 目錄+擴充屬性、SQLite 以 PRAGMA 讀取,全部只讀 metadata、不讀資料列。單一物件無權限時在文件內註明而不中斷整份匯出;所有名稱、註解與定義一律 HTML 逃逸並附 CSP 禁止腳本,錨點採雜湊,輸出限制 64 MiB 並以暫存檔原子替換。smoke test 覆蓋 SQLite 結構、逃逸與寫檔,Docker 實機矩陣驗證 MySQL 8/MariaDB/PostgreSQL 16/SQL Server 2022 的註解、定序、複合索引方向、INCLUDE、外鍵規則與檢視表定義。
1818
- **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+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。

‎mySQLPunk.Core/Providers/MySqlDatabaseSession.Structure.cs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ ORDER BY ORDINAL_POSITION
8686
await using (var command = connection.CreateCommand())
8787
{
8888
command.CommandText = """
89-
SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, INDEX_TYPE
89+
SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, INDEX_TYPE, SUB_PART, COLLATION
9090
FROM information_schema.STATISTICS
9191
WHERE TABLE_SCHEMA = @database AND TABLE_NAME = @table
9292
ORDER BY INDEX_NAME, SEQ_IN_INDEX
@@ -96,11 +96,25 @@ FROM information_schema.STATISTICS
9696
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
9797
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
9898
{
99+
// Prefix length (SUB_PART) and direction (COLLATION = 'D') are part of the index definition;
100+
// dropping them would make a prefix index look like a full-column one.
101+
var column = reader.IsDBNull(3) ? "(expression)" : reader.GetString(3);
102+
var prefix = ReadText(reader, 5);
103+
if (prefix.Length > 0 && !reader.IsDBNull(3))
104+
{
105+
column += $"({prefix})";
106+
}
107+
108+
if (ReadText(reader, 6).Equals("D", StringComparison.OrdinalIgnoreCase))
109+
{
110+
column += " DESC";
111+
}
112+
99113
rows.Add((
100114
reader.GetString(0),
101115
ReadFlag(reader, 1),
102116
Convert.ToInt64(reader.GetValue(2), System.Globalization.CultureInfo.InvariantCulture),
103-
reader.IsDBNull(3) ? "(expression)" : reader.GetString(3),
117+
column,
104118
ReadText(reader, 4),
105119
string.Empty));
106120
}

‎mySQLPunk.Core/Services/SchemaSyncScriptService.cs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,12 @@ private void CreateIndex(DatabaseObjectInfo table, StructureIndexInfo index)
488488
return;
489489
}
490490

491+
if (_provider == DatabaseProviderKind.MySql && index.Columns.Any(column => column.StartsWith("(expression)", StringComparison.Ordinal)))
492+
{
493+
AddManual($"{table.DisplayName}.{index.Name}:函式索引的運算式無法從 information_schema 取得,請手動建立。");
494+
return;
495+
}
496+
491497
switch (_provider)
492498
{
493499
case DatabaseProviderKind.PostgreSql or DatabaseProviderKind.Sqlite
@@ -613,7 +619,11 @@ private string IndexColumn(string column)
613619
var trimmed = column.Trim();
614620
var descending = trimmed.EndsWith(" DESC", StringComparison.OrdinalIgnoreCase);
615621
var name = descending ? trimmed[..^5] : trimmed;
616-
return (IsPlainIdentifier(name) ? Q(name) : name) + (descending ? " DESC" : string.Empty);
622+
var prefix = Regex.Match(name, @"^(?<name>.+)\((?<length>\d+)\)$");
623+
var quoted = prefix.Success
624+
? $"{Q(prefix.Groups["name"].Value)}({prefix.Groups["length"].Value})"
625+
: IsPlainIdentifier(name) ? Q(name) : name;
626+
return quoted + (descending ? " DESC" : string.Empty);
617627
}
618628

619629
private static bool IsPlainIdentifier(string value) => Regex.IsMatch(value, "^[A-Za-z_][A-Za-z0-9_$]*$");

‎mySQLPunk.CrossPlatform.SmokeTests/Program.cs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2715,7 +2715,7 @@ static async Task SchemaSyncLiveAsync(LiveSyncTarget target)
27152715
drop = name => $"DROP DATABASE IF EXISTS `{name}`";
27162716
sourceSql = new[]
27172717
{
2718-
"CREATE TABLE parent (id INT PRIMARY KEY, code VARCHAR(20) NOT NULL, note VARCHAR(50) NULL)",
2718+
"CREATE TABLE parent (id INT PRIMARY KEY, code VARCHAR(20) NOT NULL, note VARCHAR(50) NULL, KEY ix_parent_code (code(8), id DESC))",
27192719
"CREATE TABLE child (id INT PRIMARY KEY, parent_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0, KEY ix_child_parent (parent_id), CONSTRAINT fk_child_parent FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE CASCADE)",
27202720
"CREATE TABLE extra_table (id INT PRIMARY KEY AUTO_INCREMENT, label VARCHAR(30) COLLATE utf8mb4_bin NULL, parent_id INT NULL, KEY ix_extra_label (label), CONSTRAINT fk_extra_parent FOREIGN KEY (parent_id) REFERENCES parent(id))",
27212721
"INSERT INTO extra_table (label) VALUES ('seed')",
@@ -2825,6 +2825,14 @@ static async Task SchemaSyncLiveAsync(LiveSyncTarget target)
28252825
$"{profile.Name} 物件清單應把檢視表歸類為 View,資料表歸類為 Table");
28262826
}
28272827

2828+
if (profile.Provider == DatabaseProviderKind.MySql)
2829+
{
2830+
var parent = await session.GetTableStructureAsync(sourceDatabase, new DatabaseObjectInfo(sourceDatabase, "parent", DatabaseObjectKind.Table));
2831+
var prefixIndex = parent.Indexes.Single(index => index.Name == "ix_parent_code");
2832+
Assert(prefixIndex.Columns.SequenceEqual(new[] { "code(8)", "id DESC" }),
2833+
$"{profile.Name} 索引應保留前綴長度與降冪:{string.Join("|", prefixIndex.Columns)}");
2834+
}
2835+
28282836
await AssertSyncRoundTripAsync(session, sourceDatabase, session, targetDatabase, profile.Name);
28292837
}
28302838
finally

0 commit comments

Comments
 (0)