diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ad6ce0c..b8be944 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -20168ff7047e0ecea00acb73f413f7db \ No newline at end of file +f697e821b4acd5cf614d63d46453e8a4 \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 4499651..1904e44 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; if (sharedAllColumnsData.length > 0) { + const tiTableLower = (tableInfo.tableName || '').toLowerCase(); cols = sharedAllColumnsData - .filter(c => - (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && - (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() - ) + .filter(c => { + if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false; + const cTableLower = (c.tableName || '').toLowerCase(); + if (cTableLower === tiTableLower) return true; + // schema.table 格式匹配纯表名 + const parsed = splitSchemaAndTable(c.tableName || ''); + return (parsed.table || '').toLowerCase() === tiTableLower; + }) .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); @@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); - return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || ''); + // 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users) + const parsed = splitSchemaAndTable(c.tableName || ''); + const pureIdent = (parsed.table || '').toLowerCase(); + return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || ''); }) .map(c => { // 当前库的表字段优先级更高 @@ -788,24 +796,61 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }); - // 表提示:当前库显示表名,其他库显示 db.table 格式 + // 表提示:当前库智能处理 schema.table 格式 + // 1. 构建纯表名到 schema 列表的映射,检测同名表 + const currentDbTables = sharedTablesData.filter(t => + (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase() + ); + const tableNameToSchemas = new Map(); + for (const t of currentDbTables) { + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = (parsed.table || t.tableName || '').toLowerCase(); + const schemas = tableNameToSchemas.get(pureTable) || []; + schemas.push(parsed.schema || ''); + tableNameToSchemas.set(pureTable, schemas); + } + const tableSuggestions = sharedTablesData .filter(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - return startsWithPrefix(label || ''); + if (!isCurrentDb) { + // 跨库:用 db.table 格式匹配 + return startsWithPrefix(`${t.dbName}.${t.tableName}`); + } + // 当前库:同时用完整名和纯表名匹配 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable); }) .map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); - const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; - const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; + if (!isCurrentDb) { + const label = `${t.dbName}.${t.tableName}`; + return { + label, + kind: monaco.languages.CompletionItemKind.Class, + insertText: label, + detail: `Table (${t.dbName})`, + range, + sortText: sortGroups.tableOther + t.tableName, + }; + } + // 当前库:检查是否有跨 schema 同名表 + const parsed = splitSchemaAndTable(t.tableName || ''); + const pureTable = parsed.table || t.tableName || ''; + const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || []; + const hasDuplicate = schemas.length > 1; + // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 + const label = hasDuplicate ? t.tableName : pureTable; + const insertText = hasDuplicate ? t.tableName : pureTable; + const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, - detail: `Table (${t.dbName})`, + detail: `Table${schemaInfo}`, range, - sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, + sortText: sortGroups.tableCurrent + pureTable, }; }); diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 727b3dc..5970003 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName) } + // 设置 search_path,使所有用户 schema 下的表可以不带 schema 前缀访问 + p.ensureSearchPath(dsn) + cleanupOnFailure = false return nil } @@ -611,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position` return cols, nil } +// ensureSearchPath 查询当前数据库中所有用户 schema,通过重建连接池将 search_path 写入 DSN。 +// 仅使用 SET search_path 只对连接池中的单个连接生效,后续查询可能拿到未设置的连接。 +// 将 search_path 写入 DSN (lib/pq 支持任意 PostgreSQL runtime parameter), +// 使连接池中每个连接建立时自动携带 search_path,与金仓行为一致。 +func (p *PostgresDB) ensureSearchPath(baseDSN string) { + if p.conn == nil { + return + } + + rawSchemas := p.queryUserSchemas() + if len(rawSchemas) == 0 { + return + } + + // 构建 search_path SQL 片段(带双引号转义),用于 SET 兜底 + searchPathSQL, normalizedSchemas := buildKingbaseSearchPathCommon(rawSchemas) + if strings.TrimSpace(searchPathSQL) == "" { + return + } + + // 策略 1:将 search_path 写入 DSN,重建连接池 + // lib/pq 支持在 URL 查参数中设置任意 PostgreSQL runtime parameter, + // 如 ?search_path=ce,public,每个新连接建立时会自动 SET search_path。 + searchPathDSNVal := strings.Join(normalizedSchemas, ",") + u, parseErr := url.Parse(baseDSN) + if parseErr == nil { + q := u.Query() + q.Set("search_path", searchPathDSNVal) + u.RawQuery = q.Encode() + newDSN := u.String() + + newDB, err := sql.Open("postgres", newDSN) + if err == nil { + newDB.SetConnMaxLifetime(5 * time.Minute) + oldConn := p.conn + p.conn = newDB + if err := p.Ping(); err == nil { + _ = oldConn.Close() + logger.Infof("PostgreSQL 已通过 DSN 配置 search_path:%s", searchPathDSNVal) + return + } + // DSN 方式失败,回滚 + _ = newDB.Close() + p.conn = oldConn + logger.Warnf("PostgreSQL DSN search_path 验证失败,回退至 SET 方式") + } + } + + // 策略 2 兜底:通过 SET search_path 设置(仅影响单个连接,但聊胜于无) + timeout := p.pingTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := utils.ContextWithTimeout(timeout) + defer cancel() + + if _, err := p.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathSQL)); err != nil { + logger.Warnf("PostgreSQL 设置 search_path 失败:%v", err) + return + } + logger.Infof("PostgreSQL 已通过 SET 设置 search_path:%s", searchPathSQL) +} + +// queryUserSchemas 查询当前数据库中所有用户 schema。 +func (p *PostgresDB) queryUserSchemas() []string { + if p.conn == nil { + return nil + } + + query := `SELECT nspname FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname` + + rows, err := p.conn.Query(query) + if err != nil { + logger.Warnf("PostgreSQL 查询用户 schema 失败:%v", err) + return nil + } + defer rows.Close() + + var schemas []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + name = strings.TrimSpace(name) + if name != "" { + schemas = append(schemas, name) + } + } + return schemas +} + func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { if p.conn == nil { return fmt.Errorf("连接未打开")