🐛 fix(duckdb): 修复唯一索引元数据安全编辑定位

- DuckDB 显式唯一索引表达式返回字符串包裹标识符时,统一归一化为真实列名

- 补充 DuckDB 主键、唯一约束、显式唯一索引的真实驱动回归测试

- 将 duckdb_metadata.go 纳入 DuckDB driver-agent revision 计算,确保重装驱动后加载新元数据逻辑
This commit is contained in:
Syngnat
2026-06-04 13:52:05 +08:00
parent 4ad1d15781
commit 5b602bff75
5 changed files with 136 additions and 4 deletions

View File

@@ -11,7 +11,7 @@ func init() {
"sphinx": "src-a70c2cd4d223dac2",
"sqlserver": "src-84553484c72e7253",
"sqlite": "src-762863d48f653b89",
"duckdb": "src-3e551d777ae96d8d",
"duckdb": "src-df5d60ebb175bbbc",
"dameng": "src-596bebeaa016fc74",
"kingbase": "src-2e5a1337b0405c57",
"highgo": "src-5a29a1d3685eb6b4",

View File

@@ -350,11 +350,19 @@ func parseDuckDBListValue(raw interface{}, normalize bool) []string {
rv := reflect.ValueOf(raw)
if rv.IsValid() && rv.Kind() != reflect.String && rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
values := make([]string, 0, rv.Len())
for i := 0; i < rv.Len(); i++ {
@@ -366,7 +374,11 @@ func parseDuckDBListValue(raw interface{}, normalize bool) []string {
return normalizeDuckDBExpressionList(values)
}
return parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
values := parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize)
if !normalize {
return normalizeDuckDBExpressionList(values)
}
return values
}
func normalizeDuckDBIdentifierEntries(values []string) []string {
@@ -388,6 +400,7 @@ func normalizeDuckDBExpressionList(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
trimmed = normalizeDuckDBExpressionIdentifierLiteral(trimmed)
switch {
case trimmed == "":
continue
@@ -400,6 +413,34 @@ func normalizeDuckDBExpressionList(values []string) []string {
return normalized
}
func normalizeDuckDBExpressionIdentifierLiteral(raw string) string {
text := strings.TrimSpace(raw)
if len(text) < 2 || text[0] != '\'' || text[len(text)-1] != '\'' {
return text
}
inner := strings.TrimSpace(text[1 : len(text)-1])
inner = strings.ReplaceAll(inner, `''`, `'`)
inner = normalizeSQLIdentifierEscapes(inner)
if inner == "" {
return text
}
if strings.ContainsAny(inner, "() +-/*%") {
return text
}
if len(inner) >= 2 {
first := inner[0]
last := inner[len(inner)-1]
if (first == '"' && last == '"') || (first == '`' && last == '`') {
return inner
}
}
if strings.ContainsAny(inner, `"'`) {
return text
}
return inner
}
func parseDuckDBList(raw string, normalize bool) []string {
text := strings.TrimSpace(normalizeSQLIdentifierEscapes(raw))
if text == "" {

View File

@@ -0,0 +1,76 @@
//go:build gonavi_duckdb_driver
package db
import (
"path/filepath"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestDuckDBMetadataDetectsPrimaryAndUniqueIndexes(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "metadata.duckdb")
client := &DuckDB{}
if err := client.Connect(connection.ConnectionConfig{Type: "duckdb", Host: dbPath}); err != nil {
t.Fatalf("Connect failed: %v", err)
}
t.Cleanup(func() {
_ = client.Close()
})
if _, err := client.Exec(`
CREATE TABLE events (
id BIGINT PRIMARY KEY,
email VARCHAR UNIQUE,
name VARCHAR
);
CREATE UNIQUE INDEX idx_events_name ON events(name);
`); err != nil {
t.Fatalf("create test table failed: %v", err)
}
columns, err := client.GetColumns("main", "main.events")
if err != nil {
t.Fatalf("GetColumns failed: %v", err)
}
if len(columns) != 3 {
t.Fatalf("unexpected column count: %d, columns=%+v", len(columns), columns)
}
keysByName := map[string]string{}
for _, column := range columns {
keysByName[column.Name] = column.Key
}
if keysByName["id"] != "PRI" {
t.Fatalf("primary key metadata missing: columns=%+v", columns)
}
if keysByName["email"] != "UNI" {
t.Fatalf("unique constraint metadata missing: columns=%+v", columns)
}
indexes, err := client.GetIndexes("main", "main.events")
if err != nil {
t.Fatalf("GetIndexes failed: %v", err)
}
if !duckDBTestHasUniqueIndexColumn(indexes, "id") {
t.Fatalf("primary key index metadata missing: indexes=%+v", indexes)
}
if !duckDBTestHasUniqueIndexColumn(indexes, "email") {
t.Fatalf("unique constraint index metadata missing: indexes=%+v", indexes)
}
if !duckDBTestHasUniqueIndexColumn(indexes, "name") {
t.Fatalf("unique index metadata missing: indexes=%+v", indexes)
}
}
func duckDBTestHasUniqueIndexColumn(indexes []connection.IndexDefinition, columnName string) bool {
for _, index := range indexes {
if index.ColumnName == columnName && index.NonUnique == 0 {
return true
}
}
return false
}

View File

@@ -249,6 +249,20 @@ func TestParseDuckDBExpressionList_KeepsQuotedExpressionsIntact(t *testing.T) {
}
}
func TestParseDuckDBExpressionList_UnwrapsIdentifierLiterals(t *testing.T) {
t.Parallel()
parts := parseDuckDBExpressionList(`['"name"', '"tenant.id"', 'slug']`)
if len(parts) != 3 || parts[0] != "name" || parts[1] != "tenant.id" || parts[2] != "slug" {
t.Fatalf("unexpected expression list: %#v", parts)
}
exprParts := parseDuckDBExpressionList(`['lower("name")']`)
if len(exprParts) != 1 || exprParts[0] != `'lower("name")'` {
t.Fatalf("expression literal should be preserved: %#v", exprParts)
}
}
func containsAll(source string, needles ...string) bool {
for _, needle := range needles {
if !strings.Contains(source, needle) {

View File

@@ -116,6 +116,7 @@ sphinx:internal/db/mysql_impl.go|\
sqlserver:internal/db/sqlserver_impl.go|\
sqlite:internal/db/sqlite_impl.go|\
duckdb:internal/db/duckdb_impl.go|\
duckdb:internal/db/duckdb_metadata.go|\
duckdb:internal/db/duckdb_driver_import.go|\
duckdb:internal/db/duckdb_platform_supported.go|\
duckdb:internal/db/duckdb_platform_unsupported.go|\