diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 313d9f2..fadc7aa 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -2878,6 +2878,62 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).not.toHaveBeenCalled(); }); + it('auto aliases Oracle duplicate explicit columns before alias star expansion', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'APP'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ + columns: ['EHR_USERID_1', 'USERID', 'EHR_USERID', 'USERNAME'], + rows: [{ + EHR_USERID_1: 'emp-1', + USERID: 7, + EHR_USERID: 'emp-1', + USERNAME: 'alice', + }], + }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'USERID', key: 'PRI' }, + { name: 'EHR_USERID', key: '' }, + { name: 'USERNAME', key: '' }, + ], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['USERID'], + valueColumns: ['USERID'], + writableColumns: { + USERID: 'USERID', + EHR_USERID: 'EHR_USERID', + USERNAME: 'USERNAME', + }, + readOnly: false, + }); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('EHR_USERID AS EHR_USERID_1, a.*'); + expect(messageApi.warning).not.toHaveBeenCalled(); + }); + it.each([ 'mysql', 'mariadb', diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index a0c4444..817aa57 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -210,7 +210,13 @@ export const getLastIdentifierPart = (path: string): string => { return parts[parts.length - 1] || ''; }; -export const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => { +export type SelectItemInfo = { + expression: string; + resultName: string; + sourceName?: string; +}; + +export const resolveSelectItemInfo = (item: string): SelectItemInfo | 'all' | undefined => { const text = String(item || '').trim(); if (!text) return undefined; if (text === '*' || /\.\s*\*$/.test(text)) return 'all'; @@ -232,10 +238,16 @@ export const resolveSimpleSelectItemColumn = (item: string): { resultName: strin } } - if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined; - const sourceName = getLastIdentifierPart(expr); + if (!alias && !SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined; + const sourceName = SIMPLE_IDENTIFIER_PATH_RE.test(expr) ? getLastIdentifierPart(expr) : ''; const resultName = alias || sourceName; - return sourceName && resultName ? { resultName, sourceName } : undefined; + return resultName ? { expression: expr, resultName, sourceName: sourceName || undefined } : undefined; +}; + +export const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => { + const resolved = resolveSelectItemInfo(item); + if (!resolved || resolved === 'all' || !resolved.sourceName) return resolved === 'all' ? 'all' : undefined; + return { resultName: resolved.resultName, sourceName: resolved.sourceName }; }; export const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => { @@ -354,6 +366,57 @@ export const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`; }; +export const rewriteOracleDuplicateSelectColumns = (sql: string, tableColumnNames: string[]): string | undefined => { + const metadataNames = new Set( + tableColumnNames + .map((name) => String(name || '').trim().toLowerCase()) + .filter(Boolean), + ); + if (metadataNames.size === 0) return undefined; + + const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+[\s\S]*)$/i); + if (!match) return undefined; + + const prefix = match[1]; + const selectList = match[2].trim(); + const rest = match[3]; + const selectItems = splitTopLevelComma(selectList); + if (selectItems.length === 0) return undefined; + + const parsedItems = selectItems.map((item) => ({ + raw: String(item || '').trimEnd(), + info: resolveSelectItemInfo(item), + })); + const hasWildcard = parsedItems.some(({ info }) => info === 'all'); + if (!hasWildcard) return undefined; + + const usedResultNames = new Set(metadataNames); + parsedItems.forEach(({ info }) => { + if (!info || info === 'all') return; + const normalizedResult = String(info.resultName || '').trim().toLowerCase(); + if (normalizedResult) usedResultNames.add(normalizedResult); + }); + + let changed = false; + const rewrittenItems = parsedItems.map(({ raw, info }) => { + if (!info || info === 'all') return raw; + const normalizedResult = String(info.resultName || '').trim().toLowerCase(); + if (!metadataNames.has(normalizedResult)) return raw; + + let nextIndex = 1; + let alias = `${info.resultName}_${nextIndex}`; + while (usedResultNames.has(alias.toLowerCase())) { + nextIndex++; + alias = `${info.resultName}_${nextIndex}`; + } + usedResultNames.add(alias.toLowerCase()); + changed = true; + return `${info.expression} AS ${alias}`; + }); + + return changed ? `${prefix}${rewrittenItems.join(', ')}${rest}` : undefined; +}; + export const findWritableResultColumnForSource = (writableColumns: Record, target: string): string | undefined => { const normalizedTarget = String(target || '').trim().toLowerCase(); return Object.entries(writableColumns || {}).find(([, sourceColumn]) => ( @@ -1968,6 +2031,11 @@ export const resolveQueryLocatorPlan = async ({ const tableColumns = resCols.data as ColumnDefinition[]; const tableColumnNames = tableColumns.map(getColumnDefinitionName).filter(Boolean); + let executableStatement = statement; + if (isOracleLikeDialect(dbType) && selectInfo.selectsAll) { + const rewritten = rewriteOracleDuplicateSelectColumns(executableStatement, tableColumnNames); + if (rewritten) executableStatement = rewritten; + } const primaryKeys = tableColumns .filter((column: any) => getColumnDefinitionKey(column) === 'PRI') .map(getColumnDefinitionName) @@ -2058,7 +2126,7 @@ export const resolveQueryLocatorPlan = async ({ ]; if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) { - const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions); + const rewritten = rewriteOracleSelectAllWithExpressions(executableStatement, executableAppendExpressions); if (rewritten) { plan.executedSql = rewritten; return plan; @@ -2070,7 +2138,7 @@ export const resolveQueryLocatorPlan = async ({ return plan; } - plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions); + plan.executedSql = appendQuerySelectExpressions(executableStatement, executableAppendExpressions); return plan; } catch { const reason = translate('query_editor.message.read_only_table_locator_metadata_unavailable', {