diff --git a/.gitignore b/.gitignore index af09dda..2810a07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/bin/ # wails / node artifacts (按需) node_modules/ +frontend/wailsjs/tsconfig.json dist/ .DS_Store diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 8684f30..080276a 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + }); + 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' }, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 833e721..528233e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 = {}; - // 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(); let match; while ((match = tableRegex.exec(fullText)) !== null) {