From 37a094c3515c85511320e6ac32ffd2f136f69989 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 08:27:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(duckdb):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=85=83=E6=95=B0=E6=8D=AE=E5=85=BC=E5=AE=B9=E4=B8=8E?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E5=AE=89=E8=A3=85=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DataViewer.primary-key.test.tsx | 26 ++++++ internal/app/methods_driver.go | 3 + internal/app/methods_driver_version_test.go | 7 +- internal/db/duckdb_metadata.go | 87 ++++++++++++++++++- internal/db/duckdb_metadata_test.go | 80 +++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index be0b3fd..a9fb464 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -161,6 +161,32 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('keeps DuckDB table preview writable when unique index metadata arrives as a safe locator', async () => { + storeState.connections[0].config.type = 'duckdb'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'slug', key: '' }, { name: 'name', key: '' }], + }); + backendApp.DBGetIndexes.mockResolvedValue({ + success: true, + data: [{ name: 'events_slug_key', columnName: 'slug', nonUnique: 0, seqInIndex: 1, indexType: 'UNIQUE' }], + }); + + const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-unique', dbName: 'main', tableName: 'main.events', title: 'events' })); + + expect(dataGridState.latestProps?.pkColumns).toEqual([]); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'unique-key', + columns: ['slug'], + valueColumns: ['slug'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer.unmount(); + }); + it('enables MongoDB table preview editing through the _id locator', async () => { storeState.connections[0].config.type = 'mongodb'; storeState.connections[0].config.database = 'app'; diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index b1a9115..2393b96 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -3847,6 +3847,9 @@ func shouldPreferSourceBuildBeforeDownloadForBuildType(buildType string, driverT func shouldRequireSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { _ = selectedVersion + if shouldUseDuckDBWindowsDynamicLibrary(driverType) { + return false + } return shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) } diff --git a/internal/app/methods_driver_version_test.go b/internal/app/methods_driver_version_test.go index 10ebc48..17c9df7 100644 --- a/internal/app/methods_driver_version_test.go +++ b/internal/app/methods_driver_version_test.go @@ -515,8 +515,11 @@ func TestShouldPreferSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) } func TestShouldRequireSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) { - if !shouldRequireSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { - t.Fatal("expected development build to require local DuckDB driver-agent source build") + if shouldRequireSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { + t.Fatal("expected development build to allow DuckDB release bundle fallback after local build failure") + } + if !shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { + t.Fatal("expected development build to still prefer local DuckDB driver-agent source build before bundle fallback") } if !shouldRequireSourceBuildBeforeDownloadForBuildType("development", "mariadb", "1.9.3") { t.Fatal("expected development build alias to require local driver-agent source build") diff --git a/internal/db/duckdb_metadata.go b/internal/db/duckdb_metadata.go index 2747723..e24790b 100644 --- a/internal/db/duckdb_metadata.go +++ b/internal/db/duckdb_metadata.go @@ -2,6 +2,7 @@ package db import ( "fmt" + "reflect" "strings" "GoNavi-Wails/internal/connection" @@ -67,7 +68,7 @@ func buildDuckDBColumnDefinitions(rows []map[string]interface{}, constraintRows uniqueColumns := make(map[string]struct{}) for _, row := range constraintRows { - columnNames := parseDuckDBIdentifierList(duckDBRowString(row, "constraint_column_names")) + columnNames := duckDBRowIdentifierList(row, "constraint_column_names") switch strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "constraint_type"))) { case "PRIMARY KEY": for _, columnName := range columnNames { @@ -115,7 +116,7 @@ func buildDuckDBIndexDefinitions(constraintRows []map[string]interface{}, indexR for _, row := range constraintRows { name := strings.TrimSpace(duckDBRowString(row, "constraint_name")) constraintType := strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "constraint_type"))) - columnNames := parseDuckDBIdentifierList(duckDBRowString(row, "constraint_column_names")) + columnNames := duckDBRowIdentifierList(row, "constraint_column_names") if name == "" || len(columnNames) == 0 { continue } @@ -132,7 +133,7 @@ func buildDuckDBIndexDefinitions(constraintRows []map[string]interface{}, indexR for _, row := range indexRows { name := strings.TrimSpace(duckDBRowString(row, "index_name")) - columnNames := parseDuckDBExpressionList(duckDBRowString(row, "expressions")) + columnNames := duckDBRowExpressionList(row, "expressions") if name == "" || len(columnNames) == 0 { continue } @@ -274,6 +275,18 @@ func duckDBRowString(row map[string]interface{}, keys ...string) string { return "" } +func duckDBRowValue(row map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + for rowKey, value := range row { + if !strings.EqualFold(rowKey, key) { + continue + } + return value + } + } + return nil +} + func duckDBRowBool(row map[string]interface{}, keys ...string) bool { value := strings.TrimSpace(strings.ToLower(duckDBRowString(row, keys...))) return value == "true" || value == "1" || value == "yes" @@ -289,12 +302,80 @@ func duckDBRowInt(row map[string]interface{}, keys ...string) int { return value } +func duckDBRowIdentifierList(row map[string]interface{}, keys ...string) []string { + return parseDuckDBListValue(duckDBRowValue(row, keys...), true) +} + +func duckDBRowExpressionList(row map[string]interface{}, keys ...string) []string { + return parseDuckDBListValue(duckDBRowValue(row, keys...), false) +} + func parseDuckDBIdentifierList(raw string) []string { return parseDuckDBList(raw, true) } func parseDuckDBExpressionList(raw string) []string { values := parseDuckDBList(raw, false) + return normalizeDuckDBExpressionList(values) +} + +func parseDuckDBListValue(raw interface{}, normalize bool) []string { + if raw == nil { + return nil + } + + switch typed := raw.(type) { + case []string: + values := append([]string(nil), typed...) + if normalize { + return normalizeDuckDBIdentifierEntries(values) + } + return normalizeDuckDBExpressionList(values) + case []interface{}: + values := make([]string, 0, len(typed)) + for _, item := range typed { + values = append(values, strings.TrimSpace(fmt.Sprintf("%v", item))) + } + if normalize { + return normalizeDuckDBIdentifierEntries(values) + } + return normalizeDuckDBExpressionList(values) + } + + 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) + } + 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 := make([]string, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + values = append(values, strings.TrimSpace(fmt.Sprintf("%v", rv.Index(i).Interface()))) + } + if normalize { + return normalizeDuckDBIdentifierEntries(values) + } + return normalizeDuckDBExpressionList(values) + } + + return parseDuckDBList(strings.TrimSpace(fmt.Sprintf("%v", raw)), normalize) +} + +func normalizeDuckDBIdentifierEntries(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + normalized = append(normalized, normalizeDuckDBIdentifier(trimmed)) + } + return normalized +} + +func normalizeDuckDBExpressionList(values []string) []string { if len(values) == 0 { return values } diff --git a/internal/db/duckdb_metadata_test.go b/internal/db/duckdb_metadata_test.go index 3644837..0b2b7c3 100644 --- a/internal/db/duckdb_metadata_test.go +++ b/internal/db/duckdb_metadata_test.go @@ -90,6 +90,52 @@ func TestBuildDuckDBColumnDefinitions_MarksPrimaryAndUniqueColumns(t *testing.T) } } +func TestBuildDuckDBColumnDefinitions_SupportsArrayConstraintColumns(t *testing.T) { + t.Parallel() + + columns := buildDuckDBColumnDefinitions( + []map[string]interface{}{ + { + "column_name": "id", + "data_type": "BIGINT", + "is_nullable": "NO", + }, + { + "column_name": "tenant_id", + "data_type": "BIGINT", + "is_nullable": "NO", + }, + { + "column_name": "slug", + "data_type": "VARCHAR", + "is_nullable": "NO", + }, + }, + []map[string]interface{}{ + { + "constraint_name": "events_pkey", + "constraint_type": "PRIMARY KEY", + "constraint_column_names": []interface{}{"id", "tenant_id"}, + }, + { + "constraint_name": "events_slug_key", + "constraint_type": "UNIQUE", + "constraint_column_names": []string{"slug"}, + }, + }, + ) + + if len(columns) != 3 { + t.Fatalf("unexpected column count: %d", len(columns)) + } + if columns[0].Key != "PRI" || columns[1].Key != "PRI" { + t.Fatalf("复合主键列未正确标记: %+v", columns) + } + if columns[2].Key != "UNI" { + t.Fatalf("唯一键列未正确标记: %+v", columns[2]) + } +} + func TestBuildDuckDBIndexDefinitions_MergesConstraintsAndUniqueIndexes(t *testing.T) { t.Parallel() @@ -132,6 +178,40 @@ func TestBuildDuckDBIndexDefinitions_MergesConstraintsAndUniqueIndexes(t *testin } } +func TestBuildDuckDBIndexDefinitions_SupportsArrayMetadataRows(t *testing.T) { + t.Parallel() + + indexes := buildDuckDBIndexDefinitions( + []map[string]interface{}{ + { + "constraint_name": "events_business_key", + "constraint_type": "UNIQUE", + "constraint_column_names": []interface{}{"tenant_id", "slug"}, + }, + }, + []map[string]interface{}{ + { + "index_name": "idx_events_expr", + "is_unique": true, + "expressions": []string{`lower("slug")`}, + }, + }, + ) + + if len(indexes) != 3 { + t.Fatalf("unexpected index row count: %d", len(indexes)) + } + if indexes[0].Name != "events_business_key" || indexes[0].ColumnName != "tenant_id" || indexes[0].SeqInIndex != 1 { + t.Fatalf("约束唯一索引首列映射异常: %+v", indexes[0]) + } + if indexes[1].Name != "events_business_key" || indexes[1].ColumnName != "slug" || indexes[1].SeqInIndex != 2 { + t.Fatalf("约束唯一索引次列映射异常: %+v", indexes[1]) + } + if indexes[2].Name != "idx_events_expr" || indexes[2].ColumnName != `lower("slug")` || indexes[2].NonUnique != 0 { + t.Fatalf("表达式唯一索引映射异常: %+v", indexes[2]) + } +} + func TestNormalizeDuckDBObjectPath_PreservesCatalogSchemaAndQuotedDots(t *testing.T) { t.Parallel()