mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
🐛 fix(duckdb): 修复唯一索引元数据安全编辑定位
- DuckDB 显式唯一索引表达式返回字符串包裹标识符时,统一归一化为真实列名 - 补充 DuckDB 主键、唯一约束、显式唯一索引的真实驱动回归测试 - 将 duckdb_metadata.go 纳入 DuckDB driver-agent revision 计算,确保重装驱动后加载新元数据逻辑
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
76
internal/db/duckdb_metadata_integration_test.go
Normal file
76
internal/db/duckdb_metadata_integration_test.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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|\
|
||||
|
||||
Reference in New Issue
Block a user