diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index c50362a..3b5de86 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -334,6 +334,47 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.warning).not.toHaveBeenCalled(); }); + it('normalizes unquoted lowercase Oracle identifiers before committing query result edits', async () => { + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', 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(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'MYCIMLED', 'EDC_LOG'); + expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG'); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['ID'], + valueColumns: ['__gonavi_locator_1_ID'], + hiddenColumns: ['__gonavi_locator_1_ID'], + writableColumns: { + name: 'NAME', + }, + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(messageApi.warning).not.toHaveBeenCalled(); + }); + it('uses a unique index locator for query results without primary keys', async () => { storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; @@ -579,7 +620,8 @@ describe('QueryEditor external SQL save', () => { await Promise.resolve(); }); - expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : 'users'); + const expectedTableName = dbType === 'oracle' || dbType === 'dameng' ? 'USERS' : 'users'; + expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : expectedTableName); expect(dataGridState.latestProps?.editLocator).toBeUndefined(); expect(dataGridState.latestProps?.readOnly).toBe(true); expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c81ddef..9f6d89b 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -448,6 +448,13 @@ const findWritableResultColumnForSource = (writableColumns: Record { + const normalizedSource = String(sourceColumn || '').trim(); + if (!normalizedSource) return ''; + return tableColumnNames.find((column) => String(column || '').trim().toLowerCase() === normalizedSource.toLowerCase()) + || normalizedSource; +}; + const buildQueryLocatorAlias = (column: string, index: number): string => { const normalized = String(column || '').trim().replace(/[^A-Za-z0-9_]/g, '_').slice(0, 48) || 'column'; return `${QUERY_LOCATOR_ALIAS_PREFIX}${index}_${normalized}`; @@ -520,7 +527,8 @@ const resolveQueryLocatorPlan = async ({ ? Object.fromEntries(tableColumnNames.map((column) => [column, column])) : {}; Object.entries(selectInfo.writableColumns).forEach(([resultColumn, sourceColumn]) => { - writableColumns[resultColumn] = sourceColumn; + const metadataColumn = resolveMetadataColumnName(tableColumnNames, sourceColumn); + if (metadataColumn) writableColumns[resultColumn] = metadataColumn; }); const appendExpressions: string[] = []; const hiddenColumns: string[] = []; diff --git a/frontend/src/utils/queryResultTable.test.ts b/frontend/src/utils/queryResultTable.test.ts index d9059d3..3660930 100644 --- a/frontend/src/utils/queryResultTable.test.ts +++ b/frontend/src/utils/queryResultTable.test.ts @@ -12,6 +12,24 @@ describe('extractQueryResultTableRef', () => { }); }); + it('normalizes unquoted Oracle identifiers to their folded uppercase names', () => { + expect(extractQueryResultTableRef('select * from mycimled.edc_log fetch first 500 rows only', 'oracle', 'anonymous')) + .toEqual({ + tableName: 'MYCIMLED.EDC_LOG', + metadataDbName: 'MYCIMLED', + metadataTableName: 'EDC_LOG', + }); + }); + + it('preserves quoted Oracle identifier case', () => { + expect(extractQueryResultTableRef('SELECT * FROM "mycimled"."edc_log"', 'oracle', 'ANONYMOUS')) + .toEqual({ + tableName: 'mycimled.edc_log', + metadataDbName: 'mycimled', + metadataTableName: 'edc_log', + }); + }); + it('uses current schema for unqualified Oracle tables', () => { expect(extractQueryResultTableRef('SELECT * FROM EDC_LOG', 'oracle', 'MYCIMLED')) .toEqual({ diff --git a/frontend/src/utils/queryResultTable.ts b/frontend/src/utils/queryResultTable.ts index 60bd909..98c1b4f 100644 --- a/frontend/src/utils/queryResultTable.ts +++ b/frontend/src/utils/queryResultTable.ts @@ -16,19 +16,42 @@ const stripIdentifierQuotes = (part: string): string => { return text; }; -const normalizeQualifiedName = (raw: string): string => ( - String(raw || '') - .split('.') - .map((part) => stripIdentifierQuotes(part.trim())) - .filter(Boolean) - .join('.') -); - const isOracleLikeDialect = (dialect: string): boolean => { const normalized = String(dialect || '').trim().toLowerCase(); return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8'; }; +const isQuotedIdentifier = (part: string): boolean => { + const text = String(part || '').trim(); + if (!text) return false; + return (text.startsWith('`') && text.endsWith('`')) + || (text.startsWith('"') && text.endsWith('"')) + || (text.startsWith('[') && text.endsWith(']')); +}; + +const normalizeIdentifierPart = (part: string, dialect: string): string => { + const text = String(part || '').trim(); + const value = stripIdentifierQuotes(text); + if (!value) return ''; + if (isOracleLikeDialect(dialect) && !isQuotedIdentifier(text)) { + return value.toUpperCase(); + } + return value; +}; + +const normalizeCurrentDbName = (currentDb: string, dialect: string): string => { + const value = String(currentDb || '').trim(); + if (!value) return ''; + return isOracleLikeDialect(dialect) ? value.toUpperCase() : value; +}; + +const normalizeQualifiedNameParts = (raw: string, dialect: string): string[] => ( + String(raw || '') + .split('.') + .map((part) => normalizeIdentifierPart(part, dialect)) + .filter(Boolean) +); + export const extractQueryResultTableRef = ( sql: string, dialect: string, @@ -43,15 +66,13 @@ export const extractQueryResultTableRef = ( const tableMatch = text.match(/^\s*SELECT\s+.+?\s+FROM\s+((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})\s*(?:$|[\s;])/im); if (!tableMatch) return undefined; - const qualifiedName = normalizeQualifiedName(tableMatch[1]); - if (!qualifiedName) return undefined; - - const parts = qualifiedName.split('.').filter(Boolean); + const parts = normalizeQualifiedNameParts(tableMatch[1], dialect); + if (parts.length === 0) return undefined; const metadataTableName = parts[parts.length - 1] || ''; if (!metadataTableName) return undefined; const owner = parts.length >= 2 ? parts[parts.length - 2] : ''; - const metadataDbName = owner || currentDb || ''; + const metadataDbName = owner || normalizeCurrentDbName(currentDb, dialect); const tableName = isOracleLikeDialect(dialect) && owner ? `${owner}.${metadataTableName}` : metadataTableName;