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;