mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(postgres): PostgreSQL 支持不带 schema 前缀的表名补全与执行
- 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池 - 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效 - 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全 - 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分 - 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配
This commit is contained in:
@@ -1 +1 @@
|
|||||||
20168ff7047e0ecea00acb73f413f7db
|
f697e821b4acd5cf614d63d46453e8a4
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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("连接未打开")
|
||||||
|
|||||||
Reference in New Issue
Block a user