package app import ( "errors" "fmt" "path/filepath" "strings" "testing" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/secretstore" ) func requireDuckDBOptionalDriverRuntime(t *testing.T) { t.Helper() if !db.IsOptionalGoDriverBuildIncluded("duckdb") { t.Skip("当前构建未包含 DuckDB 可选驱动") } if ready, reason := db.DriverRuntimeSupportStatus("duckdb"); !ready { t.Skipf("DuckDB runtime 未就绪,跳过集成测试: %s", reason) } } type fakeMetadataRetryDB struct { columns []connection.ColumnDefinition indexes []connection.IndexDefinition columnsErr error indexesErr error columnCalls int indexCalls int columnSchema string columnTable string indexSchema string indexTable string } func (f *fakeMetadataRetryDB) Connect(config connection.ConnectionConfig) error { return nil } func (f *fakeMetadataRetryDB) Close() error { return nil } func (f *fakeMetadataRetryDB) Ping() error { return nil } func (f *fakeMetadataRetryDB) Query(query string) ([]map[string]interface{}, []string, error) { return nil, nil, nil } func (f *fakeMetadataRetryDB) Exec(query string) (int64, error) { return 0, nil } func (f *fakeMetadataRetryDB) GetDatabases() ([]string, error) { return nil, nil } func (f *fakeMetadataRetryDB) GetTables(dbName string) ([]string, error) { return nil, nil } func (f *fakeMetadataRetryDB) GetCreateStatement(dbName, tableName string) (string, error) { return "", nil } func (f *fakeMetadataRetryDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { f.columnCalls++ f.columnSchema = dbName f.columnTable = tableName if f.columnsErr != nil { return nil, f.columnsErr } return f.columns, nil } func (f *fakeMetadataRetryDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { return nil, nil } func (f *fakeMetadataRetryDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { f.indexCalls++ f.indexSchema = dbName f.indexTable = tableName if f.indexesErr != nil { return nil, f.indexesErr } return f.indexes, nil } func (f *fakeMetadataRetryDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { return nil, nil } func (f *fakeMetadataRetryDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { return nil, nil } var _ db.Database = (*fakeMetadataRetryDB)(nil) func TestDBGetColumnsRetriesAfterCachedConnectionRefresh(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) first := &fakeMetadataRetryDB{ columnsErr: errors.New("invalid connection"), } second := &fakeMetadataRetryDB{ columns: []connection.ColumnDefinition{ {Name: "ID", Key: "PRI"}, {Name: "username", Key: ""}, }, } instances := []*fakeMetadataRetryDB{first, second} newDatabaseFunc = func(dbType string) (db.Database, error) { next := instances[0] instances = instances[1:] return next, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetColumns(connection.ConnectionConfig{ Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root", }, "mkefu_test_new", "uk_user") if !result.Success { t.Fatalf("expected DBGetColumns success after retry, got failure: %s", result.Message) } if first.columnCalls != 1 { t.Fatalf("expected first metadata call once, got %d", first.columnCalls) } if second.columnCalls != 1 { t.Fatalf("expected retried metadata call once, got %d", second.columnCalls) } columns, ok := result.Data.([]connection.ColumnDefinition) if !ok { t.Fatalf("expected []connection.ColumnDefinition, got %T", result.Data) } if len(columns) != 2 || columns[0].Key != "PRI" { t.Fatalf("unexpected columns after retry: %#v", columns) } } func TestDBGetColumnsUsesSearchPathForPostgresPureTableMetadata(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) dbInst := &fakeMetadataRetryDB{ columns: []connection.ColumnDefinition{{Name: "id", Key: "PRI"}}, } newDatabaseFunc = func(dbType string) (db.Database, error) { return dbInst, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetColumns(connection.ConnectionConfig{ Type: "postgres", Host: "127.0.0.1", Port: 5432, User: "postgres", Database: "demo_db", }, "demo_db", "users") if !result.Success { t.Fatalf("expected DBGetColumns success, got failure: %s", result.Message) } if dbInst.columnSchema != "" || dbInst.columnTable != "users" { t.Fatalf("expected postgres pure table metadata to pass empty schema/users, got %q.%q", dbInst.columnSchema, dbInst.columnTable) } } func TestDBGetIndexesUsesSearchPathForPostgresPureTableMetadata(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) dbInst := &fakeMetadataRetryDB{ indexes: []connection.IndexDefinition{{Name: "users_email_key", ColumnName: "email", NonUnique: 0}}, } newDatabaseFunc = func(dbType string) (db.Database, error) { return dbInst, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetIndexes(connection.ConnectionConfig{ Type: "postgres", Host: "127.0.0.1", Port: 5432, User: "postgres", Database: "demo_db", }, "demo_db", "users") if !result.Success { t.Fatalf("expected DBGetIndexes success, got failure: %s", result.Message) } if dbInst.indexSchema != "" || dbInst.indexTable != "users" { t.Fatalf("expected postgres pure table index metadata to pass empty schema/users, got %q.%q", dbInst.indexSchema, dbInst.indexTable) } } func TestDBGetColumnsKeepsDatabaseForMySQLMetadata(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) dbInst := &fakeMetadataRetryDB{ columns: []connection.ColumnDefinition{{Name: "id", Key: "PRI"}}, } newDatabaseFunc = func(dbType string) (db.Database, error) { return dbInst, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetColumns(connection.ConnectionConfig{ Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root", }, "demo_db", "users") if !result.Success { t.Fatalf("expected DBGetColumns success, got failure: %s", result.Message) } if dbInst.columnSchema != "demo_db" || dbInst.columnTable != "users" { t.Fatalf("expected mysql metadata to pass database/table, got %q.%q", dbInst.columnSchema, dbInst.columnTable) } } func TestDBGetColumnsKeepsDuckDBQualifiedTableMetadata(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc verifyDriverAgentRevisionFunc = originalVerifyDriverAgentRevisionFunc }) dbInst := &fakeMetadataRetryDB{ columns: []connection.ColumnDefinition{{Name: "id", Key: "PRI"}}, } newDatabaseFunc = func(dbType string) (db.Database, error) { return dbInst, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } driverRuntimeSupportStatusFunc = func(driverType string) (bool, string) { return true, "" } verifyDriverAgentRevisionFunc = func(config connection.ConnectionConfig) error { return nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetColumns(connection.ConnectionConfig{ Type: "duckdb", Host: "D:/tmp/demo.duckdb", }, "main", "main.events") if !result.Success { t.Fatalf("expected DBGetColumns success, got failure: %s", result.Message) } if dbInst.columnSchema != "main" || dbInst.columnTable != "main.events" { t.Fatalf("expected duckdb metadata to preserve main/main.events, got %q.%q", dbInst.columnSchema, dbInst.columnTable) } } func TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) first := &fakeMetadataRetryDB{ indexesErr: errors.New("server has gone away"), } second := &fakeMetadataRetryDB{ indexes: []connection.IndexDefinition{ {Name: "PRIMARY", ColumnName: "ID", NonUnique: 0, SeqInIndex: 1, IndexType: "BTREE"}, }, } instances := []*fakeMetadataRetryDB{first, second} newDatabaseFunc = func(dbType string) (db.Database, error) { next := instances[0] instances = instances[1:] return next, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetIndexes(connection.ConnectionConfig{ Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root", }, "mkefu_test_new", "uk_user") if !result.Success { t.Fatalf("expected DBGetIndexes success after retry, got failure: %s", result.Message) } if first.indexCalls != 1 { t.Fatalf("expected first index metadata call once, got %d", first.indexCalls) } if second.indexCalls != 1 { t.Fatalf("expected retried index metadata call once, got %d", second.indexCalls) } indexes, ok := result.Data.([]connection.IndexDefinition) if !ok { t.Fatalf("expected []connection.IndexDefinition, got %T", result.Data) } if len(indexes) != 1 || indexes[0].Name != "PRIMARY" { t.Fatalf("unexpected indexes after retry: %#v", indexes) } } func TestDBGetIndexesKeepsDuckDBQualifiedTableMetadata(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc verifyDriverAgentRevisionFunc = originalVerifyDriverAgentRevisionFunc }) dbInst := &fakeMetadataRetryDB{ indexes: []connection.IndexDefinition{{Name: "events_id_pkey", ColumnName: "id", NonUnique: 0}}, } newDatabaseFunc = func(dbType string) (db.Database, error) { return dbInst, nil } resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } driverRuntimeSupportStatusFunc = func(driverType string) (bool, string) { return true, "" } verifyDriverAgentRevisionFunc = func(config connection.ConnectionConfig) error { return nil } app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) result := app.DBGetIndexes(connection.ConnectionConfig{ Type: "duckdb", Host: "D:/tmp/demo.duckdb", }, "main", "main.events") if !result.Success { t.Fatalf("expected DBGetIndexes success, got failure: %s", result.Message) } if dbInst.indexSchema != "main" || dbInst.indexTable != "main.events" { t.Fatalf("expected duckdb index metadata to preserve main/main.events, got %q.%q", dbInst.indexSchema, dbInst.indexTable) } } func TestDuckDBMetadataEndpointsReturnPrimaryKeyForQualifiedTableName(t *testing.T) { requireDuckDBOptionalDriverRuntime(t) originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } dbPath := filepath.Join(t.TempDir(), "duckdb-primary-key.duckdb") app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) config := connection.ConnectionConfig{ Type: "duckdb", Host: dbPath, } t.Cleanup(func() { app.invalidateCachedDatabase(config, nil) }) createResult := app.DBQuery(config, "main", ` CREATE TABLE main.events ( id BIGINT PRIMARY KEY, name VARCHAR ); CREATE UNIQUE INDEX idx_events_name ON main.events(name); `) if !createResult.Success { t.Fatalf("expected DuckDB setup success, got failure: %s", createResult.Message) } columnResult := app.DBGetColumns(config, "main", "main.events") if !columnResult.Success { t.Fatalf("expected DBGetColumns success, got failure: %s", columnResult.Message) } columns, ok := columnResult.Data.([]connection.ColumnDefinition) if !ok { t.Fatalf("expected []connection.ColumnDefinition, got %T", columnResult.Data) } if len(columns) == 0 { t.Fatalf("expected DuckDB columns, got %#v", columns) } if columns[0].Name != "id" || columns[0].Key != "PRI" { t.Fatalf("expected primary key metadata on first column, got %#v", columns) } indexResult := app.DBGetIndexes(config, "main", "main.events") if !indexResult.Success { t.Fatalf("expected DBGetIndexes success, got failure: %s", indexResult.Message) } indexes, ok := indexResult.Data.([]connection.IndexDefinition) if !ok { t.Fatalf("expected []connection.IndexDefinition, got %T", indexResult.Data) } if len(indexes) == 0 { t.Fatalf("expected DuckDB indexes, got %#v", indexes) } foundPrimary := false for _, index := range indexes { if index.ColumnName == "id" && index.NonUnique == 0 { foundPrimary = true break } } if !foundPrimary { t.Fatalf("expected DuckDB primary key index metadata, got %#v", indexes) } } func TestDuckDBDefinitionQueriesReloadLatestDDLForObjectEditFlow(t *testing.T) { requireDuckDBOptionalDriverRuntime(t) originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc t.Cleanup(func() { resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc }) resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { return raw, nil } dbPath := filepath.Join(t.TempDir(), "duckdb-definition-reload.duckdb") app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) config := connection.ConnectionConfig{ Type: "duckdb", Host: dbPath, } t.Cleanup(func() { app.invalidateCachedDatabase(config, nil) }) createResult := app.DBQuery(config, "main", ` CREATE VIEW main.active_users AS SELECT id FROM (VALUES (1), (2)) AS users(id); CREATE OR REPLACE MACRO main.add_one(x) AS x + 1; `) if !createResult.Success { t.Fatalf("expected DuckDB setup success, got failure: %s", createResult.Message) } viewDefinitionBefore := app.DBQuery(config, "main", ` SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`) if !viewDefinitionBefore.Success { t.Fatalf("expected initial view definition query success, got failure: %s", viewDefinitionBefore.Message) } viewRowsBefore, ok := viewDefinitionBefore.Data.([]map[string]interface{}) if !ok || len(viewRowsBefore) != 1 { t.Fatalf("expected one initial view definition row, got %#v", viewDefinitionBefore.Data) } viewTextBefore := strings.TrimSpace(stringValueIgnoreCase(viewRowsBefore[0], "view_definition")) if !strings.Contains(viewTextBefore, "SELECT id FROM") || !strings.Contains(viewTextBefore, "VALUES (1), (2)") { t.Fatalf("unexpected initial view definition: %q", viewTextBefore) } routineDefinitionBefore := app.DBQuery(config, "main", ` SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`) if !routineDefinitionBefore.Success { t.Fatalf("expected initial routine definition query success, got failure: %s", routineDefinitionBefore.Message) } routineRowsBefore, ok := routineDefinitionBefore.Data.([]map[string]interface{}) if !ok || len(routineRowsBefore) != 1 { t.Fatalf("expected one initial routine definition row, got %#v", routineDefinitionBefore.Data) } routineTextBefore := strings.TrimSpace(stringValueIgnoreCase(routineRowsBefore[0], "macro_definition")) if !strings.Contains(routineTextBefore, "x + 1") { t.Fatalf("unexpected initial routine definition: %q", routineTextBefore) } replaceResult := app.DBQuery(config, "main", ` CREATE OR REPLACE VIEW main.active_users AS SELECT id, id * 10 AS score FROM (VALUES (1), (2)) AS users(id); CREATE OR REPLACE MACRO main.add_one(x) AS x + 2; `) if !replaceResult.Success { t.Fatalf("expected DuckDB replace success, got failure: %s", replaceResult.Message) } viewDefinitionAfter := app.DBQuery(config, "main", ` SELECT view_definition FROM information_schema.views WHERE table_schema = 'main' AND table_name = 'active_users' LIMIT 1`) if !viewDefinitionAfter.Success { t.Fatalf("expected latest view definition query success, got failure: %s", viewDefinitionAfter.Message) } viewRowsAfter, ok := viewDefinitionAfter.Data.([]map[string]interface{}) if !ok || len(viewRowsAfter) != 1 { t.Fatalf("expected one latest view definition row, got %#v", viewDefinitionAfter.Data) } viewTextAfter := strings.TrimSpace(stringValueIgnoreCase(viewRowsAfter[0], "view_definition")) if !strings.Contains(viewTextAfter, "score") || !strings.Contains(viewTextAfter, "10") { t.Fatalf("expected latest view definition, got %q", viewTextAfter) } if viewTextAfter == viewTextBefore { t.Fatalf("expected latest view definition to differ from initial definition, got %q", viewTextAfter) } routineDefinitionAfter := app.DBQuery(config, "main", ` SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = 'main' AND function_name = 'add_one' LIMIT 1`) if !routineDefinitionAfter.Success { t.Fatalf("expected latest routine definition query success, got failure: %s", routineDefinitionAfter.Message) } routineRowsAfter, ok := routineDefinitionAfter.Data.([]map[string]interface{}) if !ok || len(routineRowsAfter) != 1 { t.Fatalf("expected one latest routine definition row, got %#v", routineDefinitionAfter.Data) } routineTextAfter := strings.TrimSpace(stringValueIgnoreCase(routineRowsAfter[0], "macro_definition")) if !strings.Contains(routineTextAfter, "x + 2") { t.Fatalf("expected latest routine definition, got %q", routineTextAfter) } if routineTextAfter == routineTextBefore { t.Fatalf("expected latest routine definition to differ from initial definition, got %q", routineTextAfter) } } func stringValueIgnoreCase(row map[string]interface{}, key string) string { for candidate, value := range row { if strings.EqualFold(strings.TrimSpace(candidate), strings.TrimSpace(key)) { return toStringValue(value) } } return "" } func toStringValue(value interface{}) string { switch typed := value.(type) { case string: return typed case []byte: return string(typed) default: if value == nil { return "" } return fmt.Sprint(value) } }