feat(postgres): PostgreSQL 支持不带 schema 前缀的表名补全与执行

- 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池
- 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效
- 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全
- 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分
- 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配
This commit is contained in:
Syngnat
2026-03-31 12:09:33 +08:00
parent 31644dee6b
commit 9c96246320
3 changed files with 156 additions and 13 deletions

View File

@@ -1 +1 @@
20168ff7047e0ecea00acb73f413f7db f697e821b4acd5cf614d63d46453e8a4

View File

@@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// Prefer preloaded MySQL all-columns cache // Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[]; let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
if (sharedAllColumnsData.length > 0) { if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData cols = sharedAllColumnsData
.filter(c => .filter(c => {
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() 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 })); .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
} else { } else {
const dbCols = await getColumnsByDB(tableInfo.tableName); const dbCols = await getColumnsByDB(tableInfo.tableName);
@@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
.filter(c => { .filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (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 => { .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<string, string[]>();
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 const tableSuggestions = sharedTablesData
.filter(t => { .filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; if (!isCurrentDb) {
return startsWithPrefix(label || ''); // 跨库:用 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 => { .map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; if (!isCurrentDb) {
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; 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 { return {
label, label,
kind: monaco.languages.CompletionItemKind.Class, kind: monaco.languages.CompletionItemKind.Class,
insertText, insertText,
detail: `Table (${t.dbName})`, detail: `Table${schemaInfo}`,
range, range,
sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName, sortText: sortGroups.tableCurrent + pureTable,
}; };
}); });

View File

@@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName) logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
} }
// 设置 search_path使所有用户 schema 下的表可以不带 schema 前缀访问
p.ensureSearchPath(dsn)
cleanupOnFailure = false cleanupOnFailure = false
return nil return nil
} }
@@ -611,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position`
return cols, nil 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 { func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if p.conn == nil { if p.conn == nil {
return fmt.Errorf("连接未打开") return fmt.Errorf("连接未打开")