From 5b602bff7599952cb3389aeb87d9d4c350299829 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 13:52:05 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(duckdb):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AE=89=E5=85=A8=E7=BC=96=E8=BE=91=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DuckDB 显式唯一索引表达式返回字符串包裹标识符时,统一归一化为真实列名 - 补充 DuckDB 主键、唯一约束、显式唯一索引的真实驱动回归测试 - 将 duckdb_metadata.go 纳入 DuckDB driver-agent revision 计算,确保重装驱动后加载新元数据逻辑 --- internal/db/driver_agent_revisions_gen.go | 2 +- internal/db/duckdb_metadata.go | 47 +++++++++++- .../db/duckdb_metadata_integration_test.go | 76 +++++++++++++++++++ internal/db/duckdb_metadata_test.go | 14 ++++ tools/generate-driver-agent-revisions.sh | 1 + 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 internal/db/duckdb_metadata_integration_test.go diff --git a/internal/db/driver_agent_revisions_gen.go b/internal/db/driver_agent_revisions_gen.go index 918ee1a..65f24c5 100644 --- a/internal/db/driver_agent_revisions_gen.go +++ b/internal/db/driver_agent_revisions_gen.go @@ -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", diff --git a/internal/db/duckdb_metadata.go b/internal/db/duckdb_metadata.go index c7edca5..3903385 100644 --- a/internal/db/duckdb_metadata.go +++ b/internal/db/duckdb_metadata.go @@ -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 == "" { diff --git a/internal/db/duckdb_metadata_integration_test.go b/internal/db/duckdb_metadata_integration_test.go new file mode 100644 index 0000000..153ef27 --- /dev/null +++ b/internal/db/duckdb_metadata_integration_test.go @@ -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 +} diff --git a/internal/db/duckdb_metadata_test.go b/internal/db/duckdb_metadata_test.go index 9d3e4f4..2e3cb6e 100644 --- a/internal/db/duckdb_metadata_test.go +++ b/internal/db/duckdb_metadata_test.go @@ -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) { diff --git a/tools/generate-driver-agent-revisions.sh b/tools/generate-driver-agent-revisions.sh index 4678820..d94b29f 100755 --- a/tools/generate-driver-agent-revisions.sh +++ b/tools/generate-driver-agent-revisions.sh @@ -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|\