From 02faa4586bb5078403cabe548a88878b44431dc6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 4 Jun 2026 10:49:16 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(metadata):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E6=95=B0=E6=8D=AE=E6=BA=90=E4=B8=BB=E9=94=AE?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一 PG-like 数据源字段和索引元数据查询,支持 search_path 可见表 - 兼容 snake_case、布尔别名和字符串唯一索引标记 - 修复 DuckDB main/memory 路径解析,避免误判外部 catalog - 补充前后端回归测试,覆盖可编辑结果定位和元数据重试路径 --- frontend/package.json.md5 | 2 +- .../DataViewer.primary-key.test.tsx | 20 ++ .../QueryEditor.external-sql-save.test.tsx | 40 +++ .../src/components/dataGridCopyInsert.test.ts | 14 + frontend/src/components/dataGridCopyInsert.ts | 56 +++- frontend/src/utils/columnDefinition.test.ts | 7 + frontend/src/utils/columnDefinition.ts | 49 +++- internal/app/db_context.go | 28 +- internal/app/db_context_test.go | 126 +++++++++ internal/app/methods_db.go | 4 +- .../app/methods_db_metadata_retry_test.go | 124 ++++++++- internal/db/duckdb_metadata.go | 6 + internal/db/duckdb_metadata_test.go | 14 + internal/db/highgo_impl.go | 159 +----------- internal/db/kingbase_impl.go | 245 +----------------- internal/db/kingbase_impl_test.go | 53 ++++ internal/db/metadata_value.go | 44 ++++ internal/db/pg_metadata.go | 159 ++++++++++++ internal/db/pg_metadata_test.go | 80 ++++++ internal/db/postgres_impl.go | 166 +----------- internal/db/vastbase_impl.go | 159 +----------- 21 files changed, 832 insertions(+), 723 deletions(-) create mode 100644 internal/db/metadata_value.go create mode 100644 internal/db/pg_metadata.go create mode 100644 internal/db/pg_metadata_test.go diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7396e24..bed8925 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0464f9da25e9356e61652e638c99ffe \ No newline at end of file +0295a42fd931778d85157816d79d29e5 \ No newline at end of file diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index a9fb464..c32ca05 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -137,6 +137,26 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('enables table preview editing when primary key metadata uses boolean aliases', async () => { + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ column_name: 'ID', isPrimary: true }, { column_name: 'NAME' }], + }); + + const renderer = await renderAndReload(); + + expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['ID'], + valueColumns: ['ID'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + renderer.unmount(); + }); + it('uses a unique index when the table has no primary key', async () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 66d4412..14f02b9 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1723,6 +1723,46 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).not.toHaveBeenCalled(); }); + it('uses snake_case unique index metadata for query result row locators', async () => { + storeState.connections[0].config.type = 'kingbase'; + storeState.connections[0].config.database = 'KINGBASE'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [{ column_name: 'EMAIL' }, { column_name: 'NAME' }], + }); + backendApp.DBGetIndexes.mockResolvedValueOnce({ + success: true, + data: [{ index_name: 'users_email_key', column_name: 'EMAIL', is_unique: 't', seq_in_index: '1', index_type: 'btree' }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'unique-key', + columns: ['EMAIL'], + valueColumns: ['__gonavi_locator_1_EMAIL'], + hiddenColumns: ['__gonavi_locator_1_EMAIL'], + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + }); + it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => { storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; diff --git a/frontend/src/components/dataGridCopyInsert.test.ts b/frontend/src/components/dataGridCopyInsert.test.ts index 534b8dc..40a0b91 100644 --- a/frontend/src/components/dataGridCopyInsert.test.ts +++ b/frontend/src/components/dataGridCopyInsert.test.ts @@ -108,6 +108,20 @@ describe('buildCopyInsertSQL', () => { ]); }); + it('accepts non-MySQL index metadata aliases when resolving safe unique locators', () => { + expect(resolveUniqueKeyGroupsFromIndexes([ + { index_name: 'events_slug_key', column_name: 'slug', is_unique: 't', seq_in_index: '1', index_type: 'BTREE' } as any, + { INDEX_NAME: 'events_tenant_code_key', COLUMN_NAME: 'code', UNIQUE: true, COLUMN_POSITION: 2 } as any, + { INDEX_NAME: 'events_tenant_code_key', COLUMN_NAME: 'tenant_id', UNIQUE: true, COLUMN_POSITION: 1 } as any, + { indexName: 'events_ext_key', columnName: 'external_id', indexType: 'UNIQUE' } as any, + { index_name: 'idx_note', column_name: 'note', non_unique: '1' } as any, + ])).toEqual([ + ['slug'], + ['tenant_id', 'code'], + ['external_id'], + ]); + }); + it('builds UPDATE SQL with a primary-key WHERE clause and keeps literal formatting aligned with INSERT', () => { const result = buildCopyUpdateSQL({ dbType: 'mysql', diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts index 2287b45..2b363d1 100644 --- a/frontend/src/components/dataGridCopyInsert.ts +++ b/frontend/src/components/dataGridCopyInsert.ts @@ -407,13 +407,59 @@ export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | u columns: Array<{ columnName: string; seqInIndex: number; order: number }>; }; + const readIndexProp = (index: unknown, keys: string[]): unknown => { + const source = index as Record | null | undefined; + if (!source || typeof source !== 'object') return undefined; + for (const key of keys) { + if (source[key] !== undefined && source[key] !== null) return source[key]; + } + for (const [sourceKey, raw] of Object.entries(source)) { + if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) { + return raw; + } + } + return undefined; + }; + + const readIndexText = (index: unknown, keys: string[]): string => { + const raw = readIndexProp(index, keys); + return raw === undefined || raw === null ? '' : String(raw).trim(); + }; + + const readIndexBool = (index: unknown, keys: string[]): boolean | undefined => { + const raw = readIndexProp(index, keys); + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + const text = String(raw).trim().toLowerCase(); + if (['1', 't', 'true', 'y', 'yes', 'unique'].includes(text)) return true; + if (['0', 'f', 'false', 'n', 'no', 'nonunique', 'non-unique'].includes(text)) return false; + return undefined; + }; + + const isUniqueIndex = (index: unknown): boolean => { + const nonUniqueRaw = readIndexProp(index, ['nonUnique', 'NonUnique', 'non_unique', 'NON_UNIQUE', 'Non_unique']); + if (nonUniqueRaw !== undefined && nonUniqueRaw !== null) { + if (typeof nonUniqueRaw === 'number') return nonUniqueRaw === 0; + const text = String(nonUniqueRaw).trim().toLowerCase(); + if (['0', 'false', 'f', 'no', 'n'].includes(text)) return true; + if (['1', 'true', 't', 'yes', 'y'].includes(text)) return false; + } + const unique = readIndexBool(index, ['isUnique', 'is_unique', 'IS_UNIQUE', 'unique', 'UNIQUE']); + if (unique !== undefined) return unique; + const uniqueness = readIndexText(index, ['uniqueness', 'UNIQUENESS']); + if (uniqueness.toLowerCase() === 'unique') return true; + const indexType = readIndexText(index, ['indexType', 'IndexType', 'index_type', 'INDEX_TYPE']); + return indexType.toLowerCase() === 'unique'; + }; + const buckets = new Map(); (indexes || []).forEach((index, order) => { - if (index?.nonUnique !== 0) { + if (!isUniqueIndex(index)) { return; } - const name = String(index?.name || '').trim(); - const columnName = String(index?.columnName || '').trim(); + const name = readIndexText(index, ['name', 'Name', 'indexName', 'index_name', 'INDEX_NAME']); + const columnName = readIndexText(index, ['columnName', 'ColumnName', 'column_name', 'COLUMN_NAME']); if (!name || !columnName) { return; } @@ -426,7 +472,9 @@ export const resolveUniqueKeyGroupsFromIndexes = (indexes: IndexDefinition[] | u } bucket.columns.push({ columnName, - seqInIndex: Number.isFinite(Number(index?.seqInIndex)) ? Number(index.seqInIndex) : 0, + seqInIndex: Number.isFinite(Number(readIndexProp(index, ['seqInIndex', 'SeqInIndex', 'seq_in_index', 'SEQ_IN_INDEX', 'columnPosition', 'column_position']))) + ? Number(readIndexProp(index, ['seqInIndex', 'SeqInIndex', 'seq_in_index', 'SEQ_IN_INDEX', 'columnPosition', 'column_position'])) + : 0, order, }); }); diff --git a/frontend/src/utils/columnDefinition.test.ts b/frontend/src/utils/columnDefinition.test.ts index 41122f5..cf8d6d6 100644 --- a/frontend/src/utils/columnDefinition.test.ts +++ b/frontend/src/utils/columnDefinition.test.ts @@ -28,4 +28,11 @@ describe('columnDefinition metadata normalization', () => { comment: '更新时间', }); }); + + it('maps boolean primary and unique metadata aliases to GoNavi keys', () => { + expect(getColumnDefinitionKey({ column_name: 'id', isPrimary: true })).toBe('PRI'); + expect(getColumnDefinitionKey({ column_name: 'id', primary_key: 't' })).toBe('PRI'); + expect(getColumnDefinitionKey({ column_name: 'email', is_unique: 'yes' })).toBe('UNI'); + expect(getColumnDefinitionKey({ column_name: 'id', column_key: 'primary key' })).toBe('PRI'); + }); }); diff --git a/frontend/src/utils/columnDefinition.ts b/frontend/src/utils/columnDefinition.ts index c9e85cb..ca03235 100644 --- a/frontend/src/utils/columnDefinition.ts +++ b/frontend/src/utils/columnDefinition.ts @@ -20,6 +20,34 @@ const readStringProperty = (value: unknown, keys: string[]): string => { return ''; }; +const readProperty = (value: unknown, keys: string[]): unknown => { + const source = value as Record | null | undefined; + if (!source || typeof source !== 'object') return undefined; + + for (const key of keys) { + if (source[key] !== undefined && source[key] !== null) { + return source[key]; + } + } + + for (const [sourceKey, raw] of Object.entries(source)) { + if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) { + return raw; + } + } + + return undefined; +}; + +const readBooleanProperty = (value: unknown, keys: string[]): boolean => { + const raw = readProperty(value, keys); + if (raw === undefined || raw === null) return false; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + const text = String(raw).trim().toLowerCase(); + return text === '1' || text === 't' || text === 'true' || text === 'y' || text === 'yes' || text === 'pri' || text === 'primary'; +}; + export const getColumnDefinitionName = (column: unknown): string => ( readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field']) ); @@ -28,9 +56,24 @@ export const getColumnDefinitionType = (column: unknown): string => ( readStringProperty(column, ['type', 'Type', 'DATA_TYPE', 'data_type']) ); -export const getColumnDefinitionKey = (column: unknown): string => ( - readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']) -); +export const getColumnDefinitionKey = (column: unknown): string => { + const key = readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']); + if (key) { + const normalized = key.trim(); + const lowered = normalized.toLowerCase(); + if (lowered === 'pri' || lowered === 'primary' || lowered === 'primary key') return 'PRI'; + if (lowered === 'uni' || lowered === 'unique') return 'UNI'; + if (lowered === 'mul' || lowered === 'multiple') return 'MUL'; + return normalized; + } + if (readBooleanProperty(column, ['primaryKey', 'primary_key', 'isPrimary', 'is_primary', 'IS_PRIMARY', 'pk', 'PK'])) { + return 'PRI'; + } + if (readBooleanProperty(column, ['unique', 'isUnique', 'is_unique', 'UNIQUE', 'IS_UNIQUE'])) { + return 'UNI'; + } + return ''; +}; export const getColumnDefinitionExtra = (column: unknown): string => ( readStringProperty(column, ['extra', 'Extra']) diff --git a/internal/app/db_context.go b/internal/app/db_context.go index a279bf7..cc0424e 100644 --- a/internal/app/db_context.go +++ b/internal/app/db_context.go @@ -75,7 +75,7 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string, return schema, table } if table != "" { - return "public", table + return "", table } } @@ -99,10 +99,34 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string, switch dbType { case "postgres", "kingbase", "highgo", "vastbase", "opengauss": - // PG/金仓/瀚高/海量:dbName 在 UI 里是"数据库",schema 需从 tableName 或使用默认 public。 + // PG/金仓/瀚高/海量:dbName 在 UI 里是"数据库",未限定 schema 的普通导出/DDL 路径沿用 public。 return "public", rawTable default: // MySQL:dbName 表示数据库;Oracle/达梦:dbName 表示 schema/owner。 return rawDB, rawTable } } + +func normalizeMetadataSchemaAndTable(config connection.ConnectionConfig, dbName string, tableName string) (string, string) { + schema, table := normalizeSchemaAndTable(config, dbName, tableName) + switch resolveDDLDBType(config) { + case "postgres", "kingbase", "highgo", "vastbase", "opengauss": + rawTable := strings.TrimSpace(tableName) + if rawTable == "" { + return schema, table + } + parsedSchema, parsedTable := db.SplitSQLQualifiedName(rawTable) + if parsedTable != "" { + if parsedSchema != "" { + return parsedSchema, parsedTable + } + return "", parsedTable + } + if strings.Contains(rawTable, ".") { + return schema, table + } + return "", table + default: + return schema, table + } +} diff --git a/internal/app/db_context_test.go b/internal/app/db_context_test.go index 48a6d19..bcc6463 100644 --- a/internal/app/db_context_test.go +++ b/internal/app/db_context_test.go @@ -62,6 +62,132 @@ func TestNormalizeSchemaAndTable_KingbaseNormalizesEscapedQualifiedName(t *testi } } +func TestNormalizeSchemaAndTable_KingbasePureTableUsesCurrentSearchPath(t *testing.T) { + t.Parallel() + + schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{ + Type: "kingbase", + }, "demo_db", "users") + + if schema != "" || table != "users" { + t.Fatalf("expected kingbase pure table to use current search_path, got %q.%q", schema, table) + } +} + +func TestNormalizeSchemaAndTable_PGLikePureTableKeepsPublicFallback(t *testing.T) { + t.Parallel() + + for _, dbType := range []string{"postgres", "highgo", "vastbase", "opengauss"} { + t.Run(dbType, func(t *testing.T) { + t.Parallel() + + schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{ + Type: dbType, + }, "demo_db", "users") + + if schema != "public" || table != "users" { + t.Fatalf("expected %s pure table to keep public fallback, got %q.%q", dbType, schema, table) + } + }) + } +} + +func TestNormalizeMetadataSchemaAndTable_PGLikePureTableUsesSearchPath(t *testing.T) { + t.Parallel() + + for _, dbType := range []string{"postgres", "highgo", "vastbase", "opengauss", "kingbase"} { + t.Run(dbType, func(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: dbType, + }, "demo_db", "users") + + if schema != "" || table != "users" { + t.Fatalf("expected %s metadata pure table to use search_path, got %q.%q", dbType, schema, table) + } + }) + } +} + +func TestNormalizeMetadataSchemaAndTable_PGLikeQualifiedTableKeepsSchema(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: "postgres", + }, "demo_db", `"audit.schema"."order.items"`) + + if schema != "audit.schema" || table != "order.items" { + t.Fatalf("expected metadata qualified table to keep schema/table, got %q.%q", schema, table) + } +} + +func TestNormalizeMetadataSchemaAndTable_PGLikeDottedUnquotedTableKeepsFallback(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: "postgres", + }, "demo_db", "audit.users") + + if schema != "audit" || table != "users" { + t.Fatalf("expected metadata dotted table to keep explicit schema, got %q.%q", schema, table) + } +} + +func TestNormalizeMetadataSchemaAndTable_PGLikeQuotedDottedTableUsesSearchPath(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: "postgres", + }, "demo_db", `"order.items"`) + + if schema != "" || table != "order.items" { + t.Fatalf("expected quoted dotted metadata table to use search_path, got %q.%q", schema, table) + } +} + +func TestNormalizeMetadataSchemaAndTable_NonPGLikeKeepsNormalBehavior(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: "mysql", + }, "demo_db", "users") + + if schema != "demo_db" || table != "users" { + t.Fatalf("expected mysql metadata to keep db/table behavior, got %q.%q", schema, table) + } +} + +func TestNormalizeSchemaAndTable_PGLikePureTableStillSplitsKingbaseSearchPathOnlyInMetadata(t *testing.T) { + t.Parallel() + + schema, table := normalizeSchemaAndTable(connection.ConnectionConfig{ + Type: "kingbase", + }, "demo_db", "users") + + if schema != "" || table != "users" { + t.Fatalf("expected kingbase normal path to keep existing search_path behavior, got %q.%q", schema, table) + } +} + +func TestNormalizeMetadataSchemaAndTable_PGLikePreservesNormalFallbackForQuotedQualifiedTable(t *testing.T) { + t.Parallel() + + for _, dbType := range []string{"highgo", "vastbase", "opengauss"} { + t.Run(dbType, func(t *testing.T) { + t.Parallel() + + schema, table := normalizeMetadataSchemaAndTable(connection.ConnectionConfig{ + Type: dbType, + }, "demo_db", `"audit"."users"`) + + if schema != "audit" || table != "users" { + t.Fatalf("expected %s metadata qualified table to keep schema, got %q.%q", dbType, schema, table) + } + }) + } +} + func TestNormalizeRunConfig_OceanBaseOracleKeepsServiceName(t *testing.T) { t.Parallel() diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 08be907..9a15d3a 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -1444,7 +1444,7 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: false, Message: err.Error()} } - schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + schemaName, pureTableName := normalizeMetadataSchemaAndTable(config, dbName, tableName) columns, err := dbInst.GetColumns(schemaName, pureTableName) if err != nil && shouldRefreshCachedConnection(err) { if a.invalidateCachedDatabase(runConfig, err) { @@ -1473,7 +1473,7 @@ func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: false, Message: err.Error()} } - schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName) + schemaName, pureTableName := normalizeMetadataSchemaAndTable(config, dbName, tableName) indexes, err := dbInst.GetIndexes(schemaName, pureTableName) if err != nil && shouldRefreshCachedConnection(err) { if a.invalidateCachedDatabase(runConfig, err) { diff --git a/internal/app/methods_db_metadata_retry_test.go b/internal/app/methods_db_metadata_retry_test.go index 19b1183..fefbf3b 100644 --- a/internal/app/methods_db_metadata_retry_test.go +++ b/internal/app/methods_db_metadata_retry_test.go @@ -10,12 +10,16 @@ import ( ) type fakeMetadataRetryDB struct { - columns []connection.ColumnDefinition - indexes []connection.IndexDefinition - columnsErr error - indexesErr error - columnCalls int - indexCalls int + 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 } @@ -34,6 +38,8 @@ func (f *fakeMetadataRetryDB) GetCreateStatement(dbName, tableName string) (stri } 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 } @@ -44,6 +50,8 @@ func (f *fakeMetadataRetryDB) GetAllColumns(dbName string) ([]connection.ColumnD } 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 } @@ -112,6 +120,110 @@ func TestDBGetColumnsRetriesAfterCachedConnectionRefresh(t *testing.T) { } } +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 TestDBGetIndexesRetriesAfterCachedConnectionRefresh(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc diff --git a/internal/db/duckdb_metadata.go b/internal/db/duckdb_metadata.go index e24790b..c7edca5 100644 --- a/internal/db/duckdb_metadata.go +++ b/internal/db/duckdb_metadata.go @@ -200,6 +200,12 @@ func normalizeDuckDBObjectPath(dbName string, tableName string) duckDBObjectPath case 2: if rawDB != "" { dbParts := splitDuckDBQualifiedName(rawDB) + if len(dbParts) == 1 && (strings.EqualFold(dbParts[0], "main") || strings.EqualFold(dbParts[0], "memory")) { + return duckDBObjectPath{ + Schema: normalizeDuckDBIdentifier(parts[0]), + Object: normalizeDuckDBIdentifier(parts[1]), + } + } if len(dbParts) == 1 { return duckDBObjectPath{ Catalog: normalizeDuckDBIdentifier(dbParts[0]), diff --git a/internal/db/duckdb_metadata_test.go b/internal/db/duckdb_metadata_test.go index 0b2b7c3..9d3e4f4 100644 --- a/internal/db/duckdb_metadata_test.go +++ b/internal/db/duckdb_metadata_test.go @@ -226,6 +226,20 @@ func TestNormalizeDuckDBObjectPath_PreservesCatalogSchemaAndQuotedDots(t *testin } } +func TestNormalizeDuckDBObjectPath_DoesNotTreatMainDatabaseAsExternalCatalog(t *testing.T) { + t.Parallel() + + path := normalizeDuckDBObjectPath("main", "main.events") + if path.Catalog != "" || path.Schema != "main" || path.Object != "events" { + t.Fatalf("unexpected duckdb main path: %+v", path) + } + + memoryPath := normalizeDuckDBObjectPath("memory", "main.events") + if memoryPath.Catalog != "" || memoryPath.Schema != "main" || memoryPath.Object != "events" { + t.Fatalf("unexpected duckdb memory path: %+v", memoryPath) + } +} + func TestParseDuckDBExpressionList_KeepsQuotedExpressionsIntact(t *testing.T) { t.Parallel() diff --git a/internal/db/highgo_impl.go b/internal/db/highgo_impl.go index 492d389..bfe68b9 100644 --- a/internal/db/highgo_impl.go +++ b/internal/db/highgo_impl.go @@ -250,178 +250,31 @@ func (h *HighGoDB) GetCreateStatement(dbName, tableName string) (string, error) } func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, - CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, - pg_get_expr(ad.adbin, ad.adrelid) AS column_default, - col_description(a.attrelid, a.attnum) AS comment, - CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -JOIN pg_attribute a ON a.attrelid = c.oid -LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum -LEFT JOIN ( - SELECT i.indrelid, a3.attname - FROM pg_index i - JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) - WHERE i.indisprimary -) pk ON pk.indrelid = c.oid AND pk.attname = a.attname -WHERE c.relkind IN ('r', 'p') - AND n.nspname = '%s' - AND c.relname = '%s' - AND a.attnum > 0 - AND NOT a.attisdropped -ORDER BY a.attnum`, esc(schema), esc(table)) - - data, _, err := h.Query(query) + data, _, err := h.Query(buildPGLikeColumnsMetadataQuery(schema, table)) if err != nil { return nil, err } - var columns []connection.ColumnDefinition - for _, row := range data { - col := connection.ColumnDefinition{ - Name: fmt.Sprintf("%v", row["column_name"]), - Type: fmt.Sprintf("%v", row["data_type"]), - Nullable: fmt.Sprintf("%v", row["is_nullable"]), - Key: fmt.Sprintf("%v", row["column_key"]), - Extra: "", - Comment: "", - } - - if v, ok := row["comment"]; ok && v != nil { - col.Comment = fmt.Sprintf("%v", v) - } - - if v, ok := row["column_default"]; ok && v != nil { - def := fmt.Sprintf("%v", v) - col.Default = &def - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { - col.Extra = "auto_increment" - } - } - - columns = append(columns, col) - } - return columns, nil + return buildPGLikeColumnDefinitions(data), nil } func (h *HighGoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - i.relname AS index_name, - a.attname AS column_name, - ix.indisunique AS is_unique, - x.ordinality AS seq_in_index, - am.amname AS index_type -FROM pg_class t -JOIN pg_namespace n ON n.oid = t.relnamespace -JOIN pg_index ix ON t.oid = ix.indrelid -JOIN pg_class i ON i.oid = ix.indexrelid -JOIN pg_am am ON i.relam = am.oid -JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE -JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum -WHERE t.relkind IN ('r', 'p') - AND t.relname = '%s' - AND n.nspname = '%s' -ORDER BY i.relname, x.ordinality`, esc(table), esc(schema)) - - data, _, err := h.Query(query) + data, _, err := h.Query(buildPGLikeIndexesMetadataQuery(schema, table)) if err != nil { return nil, err } - parseBool := func(v interface{}) bool { - switch val := v.(type) { - case bool: - return val - case string: - s := strings.ToLower(strings.TrimSpace(val)) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - default: - s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - } - } - - parseInt := func(v interface{}) int { - switch val := v.(type) { - case int: - return val - case int64: - return int(val) - case float64: - return int(val) - case string: - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n) - return n - default: - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n) - return n - } - } - - var indexes []connection.IndexDefinition - for _, row := range data { - isUnique := false - if v, ok := row["is_unique"]; ok && v != nil { - isUnique = parseBool(v) - } - - nonUnique := 1 - if isUnique { - nonUnique = 0 - } - - seq := 0 - if v, ok := row["seq_in_index"]; ok && v != nil { - seq = parseInt(v) - } - - indexType := "" - if v, ok := row["index_type"]; ok && v != nil { - indexType = strings.ToUpper(fmt.Sprintf("%v", v)) - } - if indexType == "" { - indexType = "BTREE" - } - - idx := connection.IndexDefinition{ - Name: fmt.Sprintf("%v", row["index_name"]), - ColumnName: fmt.Sprintf("%v", row["column_name"]), - NonUnique: nonUnique, - SeqInIndex: seq, - IndexType: indexType, - } - indexes = append(indexes, idx) - } - return indexes, nil + return buildPGLikeIndexDefinitions(data), nil } func (h *HighGoDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index 6f508ad..161a222 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -443,264 +443,31 @@ func (k *KingbaseDB) GetCreateStatement(dbName, tableName string) (string, error } func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - // 解析 schema.table 格式 - schema := strings.TrimSpace(dbName) - table := strings.TrimSpace(tableName) - - // 如果 tableName 包含 schema (格式: schema.table) - if parts := strings.SplitN(table, ".", 2); len(parts) == 2 { - parsedSchema := strings.TrimSpace(parts[0]) - parsedTable := strings.TrimSpace(parts[1]) - if parsedSchema != "" && parsedTable != "" { - schema = parsedSchema - table = parsedTable - } - } - - // 如果仍然没有 schema,使用 current_schema() - // 这样可以自动匹配当前连接的 search_path - if schema == "" { - return k.getColumnsWithCurrentSchema(table) - } - + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - // 转义函数:处理单引号,移除双引号 - esc := func(s string) string { - // 移除前后的双引号(如果存在) - s = strings.Trim(s, "\"") - // 转义单引号 - return strings.ReplaceAll(s, "'", "''") - } - - query := fmt.Sprintf(` -SELECT - a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, - CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, - pg_get_expr(ad.adbin, ad.adrelid) AS column_default, - col_description(a.attrelid, a.attnum) AS comment, - CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -JOIN pg_attribute a ON a.attrelid = c.oid -LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum -LEFT JOIN ( - SELECT i.indrelid, a3.attname - FROM pg_index i - JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) - WHERE i.indisprimary -) pk ON pk.indrelid = c.oid AND pk.attname = a.attname -WHERE c.relkind IN ('r', 'p') - AND n.nspname = '%s' - AND c.relname = '%s' - AND a.attnum > 0 - AND NOT a.attisdropped -ORDER BY a.attnum`, esc(schema), esc(table)) - - data, _, err := k.Query(query) + data, _, err := k.Query(buildPGLikeColumnsMetadataQuery(schema, table)) if err != nil { return nil, err } - var columns []connection.ColumnDefinition - for _, row := range data { - col := connection.ColumnDefinition{ - Name: fmt.Sprintf("%v", row["column_name"]), - Type: fmt.Sprintf("%v", row["data_type"]), - Nullable: fmt.Sprintf("%v", row["is_nullable"]), - Key: fmt.Sprintf("%v", row["column_key"]), - Extra: "", - Comment: "", - } - - if row["column_default"] != nil { - def := fmt.Sprintf("%v", row["column_default"]) - col.Default = &def - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { - col.Extra = "auto_increment" - } - } - - if v, ok := row["comment"]; ok && v != nil { - col.Comment = fmt.Sprintf("%v", v) - } - - columns = append(columns, col) - } - return columns, nil -} - -// getColumnsWithCurrentSchema 使用 current_schema() 查询当前schema的表 -func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection.ColumnDefinition, error) { - table := strings.TrimSpace(tableName) - if table == "" { - return nil, fmt.Errorf("表名不能为空") - } - - // 转义函数 - esc := func(s string) string { - s = strings.Trim(s, "\"") - return strings.ReplaceAll(s, "'", "''") - } - - // 使用 current_schema() 获取当前schema - query := fmt.Sprintf(` -SELECT - a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, - CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, - pg_get_expr(ad.adbin, ad.adrelid) AS column_default, - col_description(a.attrelid, a.attnum) AS comment, - CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -JOIN pg_attribute a ON a.attrelid = c.oid -LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum -LEFT JOIN ( - SELECT i.indrelid, a3.attname - FROM pg_index i - JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) - WHERE i.indisprimary -) pk ON pk.indrelid = c.oid AND pk.attname = a.attname -WHERE c.relkind IN ('r', 'p') - AND n.nspname = current_schema() - AND c.relname = '%s' - AND a.attnum > 0 - AND NOT a.attisdropped -ORDER BY a.attnum`, esc(table)) - - data, _, err := k.Query(query) - if err != nil { - return nil, err - } - - var columns []connection.ColumnDefinition - for _, row := range data { - col := connection.ColumnDefinition{ - Name: fmt.Sprintf("%v", row["column_name"]), - Type: fmt.Sprintf("%v", row["data_type"]), - Nullable: fmt.Sprintf("%v", row["is_nullable"]), - Key: fmt.Sprintf("%v", row["column_key"]), - Extra: "", - Comment: "", - } - - if row["column_default"] != nil { - def := fmt.Sprintf("%v", row["column_default"]) - col.Default = &def - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { - col.Extra = "auto_increment" - } - } - - if v, ok := row["comment"]; ok && v != nil { - col.Comment = fmt.Sprintf("%v", v) - } - - columns = append(columns, col) - } - return columns, nil + return buildPGLikeColumnDefinitions(data), nil } func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { - // 解析 schema.table 格式 - schema := strings.TrimSpace(dbName) - table := strings.TrimSpace(tableName) - - // 如果 tableName 包含 schema (格式: schema.table) - if parts := strings.SplitN(table, ".", 2); len(parts) == 2 { - parsedSchema := strings.TrimSpace(parts[0]) - parsedTable := strings.TrimSpace(parts[1]) - if parsedSchema != "" && parsedTable != "" { - schema = parsedSchema - table = parsedTable - } - } - + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - // 转义函数:处理单引号,移除双引号 - esc := func(s string) string { - s = strings.Trim(s, "\"") - return strings.ReplaceAll(s, "'", "''") - } - - // 构建查询:如果没有指定schema,使用current_schema() - var query string - if schema != "" { - query = fmt.Sprintf(` - SELECT - i.relname as index_name, - a.attname as column_name, - ix.indisunique as is_unique - FROM - pg_class t, - pg_class i, - pg_index ix, - pg_attribute a, - pg_namespace n - WHERE - t.oid = ix.indrelid - AND i.oid = ix.indexrelid - AND a.attrelid = t.oid - AND a.attnum = ANY(ix.indkey) - AND t.relkind = 'r' - AND t.relname = '%s' - AND n.oid = t.relnamespace - AND n.nspname = '%s' - `, esc(table), esc(schema)) - } else { - query = fmt.Sprintf(` - SELECT - i.relname as index_name, - a.attname as column_name, - ix.indisunique as is_unique - FROM - pg_class t, - pg_class i, - pg_index ix, - pg_attribute a, - pg_namespace n - WHERE - t.oid = ix.indrelid - AND i.oid = ix.indexrelid - AND a.attrelid = t.oid - AND a.attnum = ANY(ix.indkey) - AND t.relkind = 'r' - AND t.relname = '%s' - AND n.oid = t.relnamespace - AND n.nspname = current_schema() - `, esc(table)) - } - - data, _, err := k.Query(query) + data, _, err := k.Query(buildPGLikeIndexesMetadataQuery(schema, table)) if err != nil { return nil, err } - var indexes []connection.IndexDefinition - for _, row := range data { - nonUnique := 1 - if val, ok := row["is_unique"]; ok { - if b, ok := val.(bool); ok && b { - nonUnique = 0 - } - } - - idx := connection.IndexDefinition{ - Name: fmt.Sprintf("%v", row["index_name"]), - ColumnName: fmt.Sprintf("%v", row["column_name"]), - NonUnique: nonUnique, - IndexType: "BTREE", // Default - } - indexes = append(indexes, idx) - } - return indexes, nil + return buildPGLikeIndexDefinitions(data), nil } func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { diff --git a/internal/db/kingbase_impl_test.go b/internal/db/kingbase_impl_test.go index 8a171fd..c754d43 100644 --- a/internal/db/kingbase_impl_test.go +++ b/internal/db/kingbase_impl_test.go @@ -199,6 +199,50 @@ func TestKingbaseGetDatabasesFallsBackToCurrentDatabase(t *testing.T) { } } +func TestKingbaseGetIndexesParsesStringUniqueAndVisibleRelation(t *testing.T) { + registerFakeKingbaseDriverOnce.Do(func() { + sql.Register(fakeKingbaseDriverName, fakeKingbaseDriver{}) + }) + + db, err := sql.Open(fakeKingbaseDriverName, "") + if err != nil { + t.Fatalf("open fake kingbase db failed: %v", err) + } + defer db.Close() + + fakeKingbaseStateMu.Lock() + fakeKingbaseState.queryErr = nil + fakeKingbaseState.queryResults = map[string]fakeKingbaseQueryResult{} + fakeKingbaseState.lastQuery = "" + fakeKingbaseState.queries = nil + fakeKingbaseStateMu.Unlock() + + client := &KingbaseDB{conn: db} + indexes, err := client.GetIndexes("", "users") + if err != nil { + t.Fatalf("GetIndexes returned error: %v", err) + } + if len(indexes) != 2 { + t.Fatalf("expected two index rows, got %+v", indexes) + } + if indexes[0].Name != "users_email_key" || indexes[0].ColumnName != "tenant_id" || indexes[0].NonUnique != 0 || indexes[0].SeqInIndex != 1 { + t.Fatalf("unexpected first index row: %+v", indexes[0]) + } + if indexes[1].Name != "users_email_key" || indexes[1].ColumnName != "email" || indexes[1].NonUnique != 0 || indexes[1].SeqInIndex != 2 { + t.Fatalf("unexpected second index row: %+v", indexes[1]) + } + + fakeKingbaseStateMu.Lock() + lastQuery := fakeKingbaseState.lastQuery + fakeKingbaseStateMu.Unlock() + if !strings.Contains(lastQuery, "pg_catalog.pg_table_is_visible(t.oid)") { + t.Fatalf("expected search_path visible relation metadata query, got %s", lastQuery) + } + if strings.Contains(lastQuery, "current_schema()") || strings.Contains(lastQuery, "n.nspname = 'public'") { + t.Fatalf("metadata query should not force current_schema/public, got %s", lastQuery) + } +} + type fakeKingbaseDriver struct{} func (fakeKingbaseDriver) Open(name string) (driver.Conn, error) { @@ -230,6 +274,15 @@ func (fakeKingbaseConn) QueryContext(ctx context.Context, query string, args []d } return &fakeKingbaseRows{columns: result.columns, rows: result.rows}, nil } + if strings.Contains(query, "FROM pg_class t") && strings.Contains(query, "JOIN pg_index ix") && strings.Contains(query, "t.relname = 'users'") { + return &fakeKingbaseRows{ + columns: []string{"index_name", "column_name", "is_unique", "seq_in_index", "index_type"}, + rows: [][]driver.Value{ + {"users_email_key", "tenant_id", "t", "1", "btree"}, + {"users_email_key", "email", "t", "2", "btree"}, + }, + }, nil + } if fakeKingbaseState.queryErr != nil { return nil, fakeKingbaseState.queryErr } diff --git a/internal/db/metadata_value.go b/internal/db/metadata_value.go new file mode 100644 index 0000000..e9f8b81 --- /dev/null +++ b/internal/db/metadata_value.go @@ -0,0 +1,44 @@ +package db + +import ( + "fmt" + "strings" +) + +func parseMetadataBool(value interface{}) bool { + switch val := value.(type) { + case bool: + return val + case int: + return val != 0 + case int64: + return val != 0 + case float64: + return val != 0 + case string: + text := strings.ToLower(strings.TrimSpace(val)) + return text == "t" || text == "true" || text == "1" || text == "y" || text == "yes" || text == "unique" + default: + text := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", value))) + return text == "t" || text == "true" || text == "1" || text == "y" || text == "yes" || text == "unique" + } +} + +func parseMetadataInt(value interface{}) int { + switch val := value.(type) { + case int: + return val + case int64: + return int(val) + case float64: + return int(val) + case string: + var n int + _, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n) + return n + default: + var n int + _, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", value)), "%d", &n) + return n + } +} diff --git a/internal/db/pg_metadata.go b/internal/db/pg_metadata.go new file mode 100644 index 0000000..5d952f7 --- /dev/null +++ b/internal/db/pg_metadata.go @@ -0,0 +1,159 @@ +package db + +import ( + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +func normalizePGLikeMetadataTable(schemaName, tableName string) (string, string) { + schema := strings.TrimSpace(schemaName) + table := strings.TrimSpace(tableName) + if parsedSchema, parsedTable := SplitSQLQualifiedName(table); parsedSchema != "" && parsedTable != "" { + schema = parsedSchema + table = parsedTable + } + schema = strings.TrimSpace(normalizeSQLIdentifierEscapes(schema)) + table = strings.TrimSpace(normalizeSQLIdentifierEscapes(table)) + schema = strings.Trim(schema, `"`) + table = strings.Trim(table, `"`) + return schema, table +} + +func escapePGLikeMetadataLiteral(raw string) string { + text := strings.TrimSpace(raw) + text = strings.Trim(text, `"`) + return strings.ReplaceAll(text, "'", "''") +} + +func buildPGLikeVisibleRelationPredicate(alias string, schemaName string) string { + relAlias := strings.TrimSpace(alias) + if relAlias == "" { + relAlias = "c" + } + if strings.TrimSpace(schemaName) == "" { + return fmt.Sprintf("pg_catalog.pg_table_is_visible(%s.oid)", relAlias) + } + return fmt.Sprintf("n.nspname = '%s'", escapePGLikeMetadataLiteral(schemaName)) +} + +func buildPGLikeColumnsMetadataQuery(schemaName, tableName string) string { + return fmt.Sprintf(` +SELECT + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, + pg_get_expr(ad.adbin, ad.adrelid) AS column_default, + col_description(a.attrelid, a.attnum) AS comment, + CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum +LEFT JOIN ( + SELECT i.indrelid, a3.attname + FROM pg_index i + JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) + WHERE i.indisprimary +) pk ON pk.indrelid = c.oid AND pk.attname = a.attname +WHERE c.relkind IN ('r', 'p') + AND %s + AND c.relname = '%s' + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY a.attnum`, buildPGLikeVisibleRelationPredicate("c", schemaName), escapePGLikeMetadataLiteral(tableName)) +} + +func buildPGLikeIndexesMetadataQuery(schemaName, tableName string) string { + return fmt.Sprintf(` +SELECT + i.relname AS index_name, + a.attname AS column_name, + ix.indisunique AS is_unique, + x.ordinality AS seq_in_index, + am.amname AS index_type +FROM pg_class t +JOIN pg_namespace n ON n.oid = t.relnamespace +JOIN pg_index ix ON t.oid = ix.indrelid +JOIN pg_class i ON i.oid = ix.indexrelid +JOIN pg_am am ON i.relam = am.oid +JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE +JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum +WHERE t.relkind IN ('r', 'p') + AND t.relname = '%s' + AND %s + AND ix.indisvalid + AND ix.indpred IS NULL + AND x.ordinality <= ix.indnkeyatts + AND NOT EXISTS ( + SELECT 1 FROM unnest(ix.indkey) AS expr_key(attnum) WHERE expr_key.attnum <= 0 + ) +ORDER BY i.relname, x.ordinality`, escapePGLikeMetadataLiteral(tableName), buildPGLikeVisibleRelationPredicate("t", schemaName)) +} + +func buildPGLikeColumnDefinitions(data []map[string]interface{}) []connection.ColumnDefinition { + columns := make([]connection.ColumnDefinition, 0, len(data)) + for _, row := range data { + col := connection.ColumnDefinition{ + Name: fmt.Sprintf("%v", row["column_name"]), + Type: fmt.Sprintf("%v", row["data_type"]), + Nullable: fmt.Sprintf("%v", row["is_nullable"]), + Key: fmt.Sprintf("%v", row["column_key"]), + Extra: "", + Comment: "", + } + + if v, ok := row["comment"]; ok && v != nil { + col.Comment = fmt.Sprintf("%v", v) + } + + if v, ok := row["column_default"]; ok && v != nil { + def := fmt.Sprintf("%v", v) + col.Default = &def + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { + col.Extra = "auto_increment" + } + } + + columns = append(columns, col) + } + return columns +} + +func buildPGLikeIndexDefinitions(data []map[string]interface{}) []connection.IndexDefinition { + indexes := make([]connection.IndexDefinition, 0, len(data)) + for _, row := range data { + isUnique := false + if v, ok := row["is_unique"]; ok && v != nil { + isUnique = parseMetadataBool(v) + } + + nonUnique := 1 + if isUnique { + nonUnique = 0 + } + + seq := 0 + if v, ok := row["seq_in_index"]; ok && v != nil { + seq = parseMetadataInt(v) + } + + indexType := "" + if v, ok := row["index_type"]; ok && v != nil { + indexType = strings.ToUpper(fmt.Sprintf("%v", v)) + } + if indexType == "" { + indexType = "BTREE" + } + + indexes = append(indexes, connection.IndexDefinition{ + Name: fmt.Sprintf("%v", row["index_name"]), + ColumnName: fmt.Sprintf("%v", row["column_name"]), + NonUnique: nonUnique, + SeqInIndex: seq, + IndexType: indexType, + }) + } + return indexes +} diff --git a/internal/db/pg_metadata_test.go b/internal/db/pg_metadata_test.go new file mode 100644 index 0000000..8b59ea2 --- /dev/null +++ b/internal/db/pg_metadata_test.go @@ -0,0 +1,80 @@ +package db + +import ( + "strings" + "testing" +) + +func TestBuildPGLikeMetadataQueriesUseVisibleRelationForPureTable(t *testing.T) { + t.Parallel() + + columnQuery := buildPGLikeColumnsMetadataQuery("", "users") + if !strings.Contains(columnQuery, "pg_catalog.pg_table_is_visible(c.oid)") { + t.Fatalf("expected visible relation predicate for column metadata, got %s", columnQuery) + } + if strings.Contains(columnQuery, "n.nspname = 'public'") || strings.Contains(columnQuery, "current_schema()") { + t.Fatalf("pure table column metadata should not force public/current_schema, got %s", columnQuery) + } + + indexQuery := buildPGLikeIndexesMetadataQuery("", "users") + if !strings.Contains(indexQuery, "pg_catalog.pg_table_is_visible(t.oid)") { + t.Fatalf("expected visible relation predicate for index metadata, got %s", indexQuery) + } + if strings.Contains(indexQuery, "n.nspname = 'public'") || strings.Contains(indexQuery, "current_schema()") { + t.Fatalf("pure table index metadata should not force public/current_schema, got %s", indexQuery) + } +} + +func TestBuildPGLikeMetadataQueriesKeepExplicitSchema(t *testing.T) { + t.Parallel() + + columnQuery := buildPGLikeColumnsMetadataQuery("audit", "users") + if !strings.Contains(columnQuery, "n.nspname = 'audit'") { + t.Fatalf("expected explicit schema predicate, got %s", columnQuery) + } + if strings.Contains(columnQuery, "pg_catalog.pg_table_is_visible") { + t.Fatalf("explicit schema metadata should not use visibility predicate, got %s", columnQuery) + } +} + +func TestBuildPGLikeColumnDefinitionsMarksPrimaryKey(t *testing.T) { + t.Parallel() + + columns := buildPGLikeColumnDefinitions([]map[string]interface{}{ + { + "column_name": "id", + "data_type": "bigint", + "is_nullable": "NO", + "column_default": "nextval('users_id_seq'::regclass)", + "column_key": "PRI", + }, + }) + + if len(columns) != 1 { + t.Fatalf("unexpected column count: %d", len(columns)) + } + if columns[0].Name != "id" || columns[0].Key != "PRI" || columns[0].Extra != "auto_increment" { + t.Fatalf("unexpected primary key column: %+v", columns[0]) + } +} + +func TestBuildPGLikeIndexDefinitionsParsesStringUnique(t *testing.T) { + t.Parallel() + + indexes := buildPGLikeIndexDefinitions([]map[string]interface{}{ + { + "index_name": "users_email_key", + "column_name": "email", + "is_unique": "t", + "seq_in_index": "1", + "index_type": "btree", + }, + }) + + if len(indexes) != 1 { + t.Fatalf("unexpected index count: %d", len(indexes)) + } + if indexes[0].Name != "users_email_key" || indexes[0].ColumnName != "email" || indexes[0].NonUnique != 0 || indexes[0].SeqInIndex != 1 { + t.Fatalf("unexpected unique index metadata: %+v", indexes[0]) + } +} diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index fefce41..7753203 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -355,185 +355,31 @@ func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error } func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, - CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, - pg_get_expr(ad.adbin, ad.adrelid) AS column_default, - col_description(a.attrelid, a.attnum) AS comment, - CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -JOIN pg_attribute a ON a.attrelid = c.oid -LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum -LEFT JOIN ( - SELECT i.indrelid, a3.attname - FROM pg_index i - JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) - WHERE i.indisprimary -) pk ON pk.indrelid = c.oid AND pk.attname = a.attname -WHERE c.relkind IN ('r', 'p') - AND n.nspname = '%s' - AND c.relname = '%s' - AND a.attnum > 0 - AND NOT a.attisdropped -ORDER BY a.attnum`, esc(schema), esc(table)) - - data, _, err := p.Query(query) + data, _, err := p.Query(buildPGLikeColumnsMetadataQuery(schema, table)) if err != nil { return nil, err } - var columns []connection.ColumnDefinition - for _, row := range data { - col := connection.ColumnDefinition{ - Name: fmt.Sprintf("%v", row["column_name"]), - Type: fmt.Sprintf("%v", row["data_type"]), - Nullable: fmt.Sprintf("%v", row["is_nullable"]), - Key: fmt.Sprintf("%v", row["column_key"]), - Extra: "", - Comment: "", - } - - if v, ok := row["comment"]; ok && v != nil { - col.Comment = fmt.Sprintf("%v", v) - } - - if v, ok := row["column_default"]; ok && v != nil { - def := fmt.Sprintf("%v", v) - col.Default = &def - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { - col.Extra = "auto_increment" - } - } - - columns = append(columns, col) - } - return columns, nil + return buildPGLikeColumnDefinitions(data), nil } func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - i.relname AS index_name, - a.attname AS column_name, - ix.indisunique AS is_unique, - x.ordinality AS seq_in_index, - am.amname AS index_type -FROM pg_class t -JOIN pg_namespace n ON n.oid = t.relnamespace -JOIN pg_index ix ON t.oid = ix.indrelid -JOIN pg_class i ON i.oid = ix.indexrelid -JOIN pg_am am ON i.relam = am.oid -JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE -JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum -WHERE t.relkind IN ('r', 'p') - AND t.relname = '%s' - AND n.nspname = '%s' - AND ix.indisvalid - AND ix.indpred IS NULL - AND x.ordinality <= ix.indnkeyatts - AND NOT EXISTS ( - SELECT 1 FROM unnest(ix.indkey) AS expr_key(attnum) WHERE expr_key.attnum <= 0 - ) -ORDER BY i.relname, x.ordinality`, esc(table), esc(schema)) - - data, _, err := p.Query(query) + data, _, err := p.Query(buildPGLikeIndexesMetadataQuery(schema, table)) if err != nil { return nil, err } - parseBool := func(v interface{}) bool { - switch val := v.(type) { - case bool: - return val - case string: - s := strings.ToLower(strings.TrimSpace(val)) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - default: - s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - } - } - - parseInt := func(v interface{}) int { - switch val := v.(type) { - case int: - return val - case int64: - return int(val) - case float64: - return int(val) - case string: - // best effort - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(val), "%d", &n) - return n - default: - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", v)), "%d", &n) - return n - } - } - - var indexes []connection.IndexDefinition - for _, row := range data { - isUnique := false - if v, ok := row["is_unique"]; ok && v != nil { - isUnique = parseBool(v) - } - - nonUnique := 1 - if isUnique { - nonUnique = 0 - } - - seq := 0 - if v, ok := row["seq_in_index"]; ok && v != nil { - seq = parseInt(v) - } - - indexType := "" - if v, ok := row["index_type"]; ok && v != nil { - indexType = strings.ToUpper(fmt.Sprintf("%v", v)) - } - if indexType == "" { - indexType = "BTREE" - } - - idx := connection.IndexDefinition{ - Name: fmt.Sprintf("%v", row["index_name"]), - ColumnName: fmt.Sprintf("%v", row["column_name"]), - NonUnique: nonUnique, - SeqInIndex: seq, - IndexType: indexType, - } - indexes = append(indexes, idx) - } - return indexes, nil + return buildPGLikeIndexDefinitions(data), nil } func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { diff --git a/internal/db/vastbase_impl.go b/internal/db/vastbase_impl.go index 7bb5521..a1ece0a 100644 --- a/internal/db/vastbase_impl.go +++ b/internal/db/vastbase_impl.go @@ -249,178 +249,31 @@ func (v *VastbaseDB) GetCreateStatement(dbName, tableName string) (string, error } func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, - CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, - pg_get_expr(ad.adbin, ad.adrelid) AS column_default, - col_description(a.attrelid, a.attnum) AS comment, - CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -JOIN pg_attribute a ON a.attrelid = c.oid -LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum -LEFT JOIN ( - SELECT i.indrelid, a3.attname - FROM pg_index i - JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey) - WHERE i.indisprimary -) pk ON pk.indrelid = c.oid AND pk.attname = a.attname -WHERE c.relkind IN ('r', 'p') - AND n.nspname = '%s' - AND c.relname = '%s' - AND a.attnum > 0 - AND NOT a.attisdropped -ORDER BY a.attnum`, esc(schema), esc(table)) - - data, _, err := v.Query(query) + data, _, err := v.Query(buildPGLikeColumnsMetadataQuery(schema, table)) if err != nil { return nil, err } - var columns []connection.ColumnDefinition - for _, row := range data { - col := connection.ColumnDefinition{ - Name: fmt.Sprintf("%v", row["column_name"]), - Type: fmt.Sprintf("%v", row["data_type"]), - Nullable: fmt.Sprintf("%v", row["is_nullable"]), - Key: fmt.Sprintf("%v", row["column_key"]), - Extra: "", - Comment: "", - } - - if val, ok := row["comment"]; ok && val != nil { - col.Comment = fmt.Sprintf("%v", val) - } - - if val, ok := row["column_default"]; ok && val != nil { - def := fmt.Sprintf("%v", val) - col.Default = &def - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") { - col.Extra = "auto_increment" - } - } - - columns = append(columns, col) - } - return columns, nil + return buildPGLikeColumnDefinitions(data), nil } func (v *VastbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { - schema := strings.TrimSpace(dbName) - if schema == "" { - schema = "public" - } - table := strings.TrimSpace(tableName) + schema, table := normalizePGLikeMetadataTable(dbName, tableName) if table == "" { return nil, fmt.Errorf("表名不能为空") } - esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") } - - query := fmt.Sprintf(` -SELECT - i.relname AS index_name, - a.attname AS column_name, - ix.indisunique AS is_unique, - x.ordinality AS seq_in_index, - am.amname AS index_type -FROM pg_class t -JOIN pg_namespace n ON n.oid = t.relnamespace -JOIN pg_index ix ON t.oid = ix.indrelid -JOIN pg_class i ON i.oid = ix.indexrelid -JOIN pg_am am ON i.relam = am.oid -JOIN unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality) ON TRUE -JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum -WHERE t.relkind IN ('r', 'p') - AND t.relname = '%s' - AND n.nspname = '%s' -ORDER BY i.relname, x.ordinality`, esc(table), esc(schema)) - - data, _, err := v.Query(query) + data, _, err := v.Query(buildPGLikeIndexesMetadataQuery(schema, table)) if err != nil { return nil, err } - parseBool := func(val interface{}) bool { - switch v := val.(type) { - case bool: - return v - case string: - s := strings.ToLower(strings.TrimSpace(v)) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - default: - s := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", val))) - return s == "t" || s == "true" || s == "1" || s == "y" || s == "yes" - } - } - - parseInt := func(val interface{}) int { - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case string: - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(v), "%d", &n) - return n - default: - var n int - _, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprintf("%v", val)), "%d", &n) - return n - } - } - - var indexes []connection.IndexDefinition - for _, row := range data { - isUnique := false - if val, ok := row["is_unique"]; ok && val != nil { - isUnique = parseBool(val) - } - - nonUnique := 1 - if isUnique { - nonUnique = 0 - } - - seq := 0 - if val, ok := row["seq_in_index"]; ok && val != nil { - seq = parseInt(val) - } - - indexType := "" - if val, ok := row["index_type"]; ok && val != nil { - indexType = strings.ToUpper(fmt.Sprintf("%v", val)) - } - if indexType == "" { - indexType = "BTREE" - } - - idx := connection.IndexDefinition{ - Name: fmt.Sprintf("%v", row["index_name"]), - ColumnName: fmt.Sprintf("%v", row["column_name"]), - NonUnique: nonUnique, - SeqInIndex: seq, - IndexType: indexType, - } - indexes = append(indexes, idx) - } - return indexes, nil + return buildPGLikeIndexDefinitions(data), nil } func (v *VastbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {