🐛 fix(query-editor): 修复跨库查询字段补全缺失

- 统一 QueryEditor 中库表标识符与表引用解析规则
- 修复 MySQL 反引号及中划线库名场景下的 WHERE 字段补全
- 新增跨库字段补全回归测试
Close #533
This commit is contained in:
Syngnat
2026-06-14 16:59:34 +08:00
parent 3da3a3fb13
commit 9e224d0067
3 changed files with 94 additions and 39 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ build/bin/
# wails / node artifacts (按需)
node_modules/
frontend/wailsjs/tsconfig.json
dist/
.DS_Store

View File

@@ -1096,6 +1096,72 @@ describe('QueryEditor external SQL save', () => {
});
});
it('suggests columns in WHERE for cross-database MySQL tables with quoted hyphenated database names', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({
success: true,
data: [{ Database: 'sanpin' }, { Database: 'ccbim-document-07' }],
});
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => {
if (dbName === 'sanpin') {
return { success: true, data: [{ Table: 'orders' }] };
}
if (dbName === 'ccbim-document-07') {
return { success: true, data: [{ Table: 'doc' }] };
}
return { success: true, data: [] };
});
backendApp.DBGetAllColumns.mockImplementation(async (_config: any, dbName: string) => {
if (dbName === 'sanpin') {
return {
success: true,
data: [{ tableName: 'orders', name: 'id', type: 'bigint' }],
};
}
if (dbName === 'ccbim-document-07') {
return {
success: true,
data: [
{ tableName: 'doc', name: 'node_id', type: 'varchar(64)' },
{ tableName: 'doc', name: 'node_name', type: 'varchar(255)' },
],
};
}
return { success: true, data: [] };
});
editorState.value = 'SELECT *\nFROM `ccbim-document-07`.doc\nWHERE no';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'sanpin' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 3, column: 'WHERE no'.length + 1 },
);
const labels = result.suggestions.map((item: any) => item.label);
expect(labels).toContain('node_id');
expect(labels).toContain('node_name');
await act(async () => {
renderer.unmount();
});
});
it('resolves database and table targets for ctrl/cmd navigation', () => {
const tables = [
{ dbName: 'main', tableName: 'users' },

View File

@@ -354,7 +354,7 @@ const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[
if (!selectAllFound) return undefined;
const fromTrimmed = fromTail.trimStart();
const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/);
const tableMatch = fromTrimmed.match(QUERY_EDITOR_SQL_LEADING_IDENTIFIER_PATH_REGEX);
if (!tableMatch) return undefined;
const tableText = tableMatch[1];
@@ -907,6 +907,25 @@ type QueryEditorHoverTarget =
| { kind: 'column'; dbName: string; tableName: string; columnName: string; type?: string; comment?: string; schemaName?: string; range: { startColumn: number; endColumn: number } };
const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/;
const QUERY_EDITOR_SQL_UNQUOTED_IDENTIFIER_PATTERN = '[A-Za-z_][A-Za-z0-9_$]*';
const QUERY_EDITOR_SQL_QUOTED_IDENTIFIER_PATTERN = '(?:`[^`]+`|"[^"]+"|\\[[^\\]]+\\])';
const QUERY_EDITOR_SQL_IDENTIFIER_PATTERN = `(?:${QUERY_EDITOR_SQL_QUOTED_IDENTIFIER_PATTERN}|${QUERY_EDITOR_SQL_UNQUOTED_IDENTIFIER_PATTERN})`;
const QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN = `${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN}(?:\\s*\\.\\s*${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN}){0,2}`;
const QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX = new RegExp(
`(${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN})\\s*\\.\\s*(${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN})\\s*\\.\\s*([A-Za-z0-9_$]*)$`,
);
const QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX = new RegExp(
`(${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN})\\s*\\.\\s*([A-Za-z0-9_$]*)$`,
);
const QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX = new RegExp(
`\\b(?:FROM|JOIN|UPDATE|INTO|DELETE\\s+FROM)\\s+(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})`,
'gi',
);
const QUERY_EDITOR_SQL_ALIAS_REFERENCE_REGEX = new RegExp(
`\\b(?:FROM|JOIN|UPDATE|INTO|DELETE\\s+FROM)\\s+(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})(?:\\s+(?:AS\\s+)?(${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN}))?`,
'gi',
);
const QUERY_EDITOR_SQL_LEADING_IDENTIFIER_PATH_REGEX = new RegExp(`^(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})([\\s\\S]*)$`);
const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000;
const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800;
@@ -1168,7 +1187,8 @@ const buildQueryEditorAliasMap = (
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
]);
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi;
const aliasRegex = QUERY_EDITOR_SQL_ALIAS_REFERENCE_REGEX;
aliasRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeCompletionQualifiedName(match[1] || '');
@@ -3151,7 +3171,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const stripQuotes = stripCompletionIdentifierQuotes;
const normalizeQualifiedName = normalizeCompletionQualifiedName;
const getLastPart = getCompletionQualifiedNameLastPart;
const splitSchemaAndTable = splitCompletionSchemaAndTable;
const buildConnConfig = () => {
@@ -3233,7 +3252,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
// 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列
const threePartMatch = linePrefix.match(/([`"]?\w+[`"]?)\.([`"]?\w+[`"]?)\.(\w*)$/);
const threePartMatch = linePrefix.match(QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX);
if (threePartMatch) {
const dbPart = stripQuotes(threePartMatch[1]);
const tablePart = stripQuotes(threePartMatch[2]);
@@ -3262,7 +3281,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
// 1) 两段式 qualifier.xxx 格式
const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_]\w*[`"]?)\.(\w*)$/);
const qualifierMatch = linePrefix.match(QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX);
if (qualifierMatch) {
const qualifier = stripQuotes(qualifierMatch[1]);
const prefix = (qualifierMatch[2] || '').toLowerCase();
@@ -3322,39 +3341,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
// 否则检查是否是表别名或表名,提示列
const reserved = new Set([
'where', 'on', 'group', 'order', 'limit', 'having',
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
]);
const aliasMap: Record<string, {dbName: string, tableName: string}> = {};
// Capture table and optional alias, support db.table format
const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi;
let m;
while ((m = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeQualifiedName(m[1] || '');
if (!tableIdent) continue;
// 解析 db.table 或 table 格式
const parts = tableIdent.split('.');
let dbName = sharedCurrentDb || '';
let tableName = tableIdent;
if (parts.length === 2) {
dbName = parts[0];
tableName = parts[1];
}
const shortTable = getLastPart(tableIdent);
// 用表名作为 qualifier
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
const a = stripQuotes(m[2] || '').trim();
if (!a) continue;
const al = a.toLowerCase();
if (reserved.has(al)) continue;
aliasMap[al] = { dbName, tableName };
}
const aliasMap = buildQueryEditorAliasMap(fullText, sharedCurrentDb || '');
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
@@ -3401,7 +3388,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
// 2) global/table/column completion
const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)/gi;
const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX;
tableRegex.lastIndex = 0;
const foundTables = new Set<string>();
let match;
while ((match = tableRegex.exec(fullText)) !== null) {