From c1ebce4ef5079d4334a69cfea704c783e4ac53d4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 29 Apr 2026 20:07:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E5=8D=95=E8=A1=A8=E6=9F=A5=E8=AF=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=88=97=E7=BA=A7=E7=BC=96=E8=BE=91=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询编辑:支持简单表列与表达式列混合展示 - 编辑安全:仅允许真实表列编辑,表达式列保持只读 - 提交流程:支持结果列别名映射回真实表字段 - 测试覆盖:补充聚合查询静默只读与列级提交用例 --- frontend/package.json.md5 | 2 +- frontend/src/components/DataGrid.ddl.test.tsx | 42 ++++++++ frontend/src/components/DataGrid.tsx | 11 ++- .../QueryEditor.external-sql-save.test.tsx | 97 +++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 44 +++++---- frontend/src/utils/rowLocator.ts | 19 ++++ 6 files changed, 193 insertions(+), 22 deletions(-) diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index bed8925..7396e24 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0295a42fd931778d85157816d79d29e5 \ No newline at end of file +d0464f9da25e9356e61652e638c99ffe \ No newline at end of file diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 0ca01fe..4320d6a 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -286,6 +286,48 @@ describe('DataGrid commit change set', () => { }); }); + it('commits only writable result columns and maps aliases back to table columns', () => { + const result = buildDataGridCommitChangeSet({ + addedRows: [], + modifiedRows: { + 'row-1': { + [GONAVI_ROW_KEY]: 'row-1', + DISPLAY_NAME: 'new-name', + NAME_UPPER: 'NEW-NAME', + }, + }, + deletedRowKeys: new Set(), + data: [{ + [GONAVI_ROW_KEY]: 'row-1', + ID: 7, + DISPLAY_NAME: 'old-name', + NAME_UPPER: 'OLD-NAME', + }], + editLocator: { + strategy: 'primary-key', + columns: ['ID'], + valueColumns: ['ID'], + writableColumns: { + DISPLAY_NAME: 'NAME', + }, + readOnly: false, + }, + visibleColumnNames: ['DISPLAY_NAME', 'NAME_UPPER'], + rowKeyToString, + normalizeCommitCellValue: normalizeValue, + shouldCommitColumn: commitColumnGuard, + }); + + expect(result).toEqual({ + ok: true, + changes: { + inserts: [], + updates: [{ keys: { ID: 7 }, values: { NAME: 'new-name' } }], + deletes: [], + }, + }); + }); + it('fails closed when no safe locator is available', () => { const result = buildDataGridCommitChangeSet({ addedRows: [], diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 70f4b69..be0c55b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -81,7 +81,8 @@ import { } from '../utils/dataGridFind'; import { filterHiddenLocatorColumns, - isHiddenLocatorColumn, + isWritableResultColumn, + resolveWritableColumnName, resolveRowLocatorValues, type EditRowLocator, } from '../utils/rowLocator'; @@ -1004,9 +1005,11 @@ export const buildDataGridCommitChangeSet = ({ const normalizedValues: Record = {}; Object.entries(values).forEach(([col, val]) => { if (!shouldCommitColumn(col)) return; + const commitColumnName = resolveWritableColumnName(col, editLocator); + if (!commitColumnName) return; const normalizedVal = normalizeCommitCellValue(col, val, mode); if (normalizedVal !== undefined) { - normalizedValues[col] = normalizedVal; + normalizedValues[commitColumnName] = normalizedVal; } }); return normalizedValues; @@ -1120,7 +1123,7 @@ const DataGrid: React.FC = ({ ); const shouldCommitColumn = useCallback((columnName: string): boolean => { const normalized = String(columnName || '').trim(); - return normalized !== GONAVI_ROW_KEY && !isHiddenLocatorColumn(normalized, effectiveEditLocator); + return normalized !== GONAVI_ROW_KEY && isWritableResultColumn(normalized, effectiveEditLocator); }, [effectiveEditLocator]); const canModifyData = !readOnly && !!tableName && !!effectiveEditLocator && !effectiveEditLocator.readOnly && effectiveEditLocator.strategy !== 'none'; const showColumnComment = queryOptions?.showColumnComment ?? true; @@ -3768,7 +3771,7 @@ const DataGrid: React.FC = ({ }), sorter: onSort ? { multiple: displayColumnNames.indexOf(key) + 1 } : false, sortOrder: (sortInfo.find(s => s.columnKey === key && s.enabled !== false)?.order || null) as SortOrder | undefined, - editable: canModifyData, // Only editable if table name known and not readonly + editable: canModifyData && isWritableResultColumn(key, effectiveEditLocator), render: (text: any) => (
{renderCellDisplayValue(text, normalizedPageFindText)} diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 6f64767..df52733 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -445,4 +445,101 @@ describe('QueryEditor external SQL save', () => { expect(dataGridState.latestProps?.readOnly).toBe(true); expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。'); }); + + it('allows editable table columns while leaving expression columns out of commits', async () => { + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ + columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'], + rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: '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(dataGridState.latestProps?.tableName).toBe('users'); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'primary-key', + columns: ['ID'], + valueColumns: ['__gonavi_locator_1_ID'], + hiddenColumns: ['__gonavi_locator_1_ID'], + writableColumns: { + DISPLAY_NAME: 'NAME', + }, + readOnly: false, + }); + expect(dataGridState.latestProps?.readOnly).toBe(false); + expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`'); + expect(messageApi.warning).not.toHaveBeenCalled(); + }); + + it.each([ + 'mysql', + 'mariadb', + 'diros', + 'sphinx', + 'postgres', + 'kingbase', + 'highgo', + 'vastbase', + 'sqlserver', + 'sqlite', + 'duckdb', + 'oracle', + 'dameng', + 'tdengine', + 'clickhouse', + ])( + 'keeps aggregate query results silently read-only for %s', + async (dbType) => { + storeState.connections[0].config.type = dbType; + storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main'; + const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse'; + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }], + }); + + 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?.tableName).toBe(forceReadOnlyQueryResult ? undefined : 'users'); + expect(dataGridState.latestProps?.editLocator).toBeUndefined(); + expect(dataGridState.latestProps?.readOnly).toBe(true); + expect(backendApp.DBGetColumns).not.toHaveBeenCalled(); + expect(backendApp.DBGetIndexes).not.toHaveBeenCalled(); + expect(messageApi.warning).not.toHaveBeenCalled(); + }, + ); }); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c3c7093..ae76811 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -203,7 +203,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({ type SimpleSelectInfo = { selectsAll: boolean; - resultColumns: string[]; + writableColumns: Record; }; type QueryStatementPlan = { @@ -289,7 +289,7 @@ const getLastIdentifierPart = (path: string): string => { return parts[parts.length - 1] || ''; }; -const resolveSimpleSelectItemColumn = (item: string): { name: string } | 'all' | undefined => { +const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => { const text = String(item || '').trim(); if (!text) return undefined; if (text === '*' || /\.\s*\*$/.test(text)) return 'all'; @@ -312,8 +312,9 @@ const resolveSimpleSelectItemColumn = (item: string): { name: string } | 'all' | } if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined; - const name = alias || getLastIdentifierPart(expr); - return name ? { name } : undefined; + const sourceName = getLastIdentifierPart(expr); + const resultName = alias || sourceName; + return sourceName && resultName ? { resultName, sourceName } : undefined; }; const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => { @@ -322,18 +323,18 @@ const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => { const selectList = match[1].trim(); if (!selectList || /^DISTINCT\b/i.test(selectList)) return undefined; - const resultColumns: string[] = []; + const writableColumns: Record = {}; let selectsAll = false; for (const item of splitTopLevelComma(selectList)) { const resolved = resolveSimpleSelectItemColumn(item); - if (!resolved) return undefined; + if (!resolved) continue; if (resolved === 'all') { selectsAll = true; continue; } - resultColumns.push(resolved.name); + writableColumns[resolved.resultName] = resolved.sourceName; } - return { selectsAll, resultColumns }; + return { selectsAll, writableColumns }; }; const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => { @@ -344,9 +345,11 @@ const appendQuerySelectExpressions = (sql: string, expressions: string[]): strin ); }; -const findQueryResultColumn = (columns: string[], target: string): string | undefined => { +const findWritableResultColumnForSource = (writableColumns: Record, target: string): string | undefined => { const normalizedTarget = String(target || '').trim().toLowerCase(); - return (columns || []).find((column) => String(column || '').trim().toLowerCase() === normalizedTarget); + return Object.entries(writableColumns || {}).find(([, sourceColumn]) => ( + String(sourceColumn || '').trim().toLowerCase() === normalizedTarget + ))?.[0]; }; const buildQueryLocatorAlias = (column: string, index: number): string => { @@ -388,9 +391,10 @@ const resolveQueryLocatorPlan = async ({ const selectInfo = parseSimpleSelectInfo(statement); if (!selectInfo) { - const reason = '当前 SELECT 列表不是简单列或 *,无法安全提交修改。'; - plan.editLocator = buildQueryReadOnlyLocator(reason); - plan.warning = `查询结果保持只读:${reason}`; + // 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。 + return plan; + } + if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) { return plan; } @@ -408,6 +412,7 @@ const resolveQueryLocatorPlan = async ({ } const tableColumns = resCols.data as ColumnDefinition[]; + const tableColumnNames = tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean); const primaryKeys = tableColumns .filter((column: any) => column?.key === 'PRI') .map((column: any) => String(column?.name || '').trim()) @@ -415,15 +420,18 @@ const resolveQueryLocatorPlan = async ({ const indexes = resIndexes?.success && Array.isArray(resIndexes.data) ? resIndexes.data as IndexDefinition[] : []; - const selectedColumns = selectInfo.selectsAll - ? tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean) - : selectInfo.resultColumns; + const writableColumns: Record = selectInfo.selectsAll + ? Object.fromEntries(tableColumnNames.map((column) => [column, column])) + : {}; + Object.entries(selectInfo.writableColumns).forEach(([resultColumn, sourceColumn]) => { + writableColumns[resultColumn] = sourceColumn; + }); const appendExpressions: string[] = []; const hiddenColumns: string[] = []; const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => { const valueColumns = locatorColumns.map((column, index) => { - const selectedColumn = findQueryResultColumn(selectedColumns, column); + const selectedColumn = findWritableResultColumnForSource(writableColumns, column); if (selectedColumn) return selectedColumn; const alias = buildQueryLocatorAlias(column, index + 1); appendExpressions.push(buildQueryLocatorColumnExpression(dbType, column, alias)); @@ -435,6 +443,7 @@ const resolveQueryLocatorPlan = async ({ columns: locatorColumns, valueColumns, hiddenColumns: hiddenColumns.length > 0 ? [...hiddenColumns] : undefined, + writableColumns, readOnly: false, }; }; @@ -454,6 +463,7 @@ const resolveQueryLocatorPlan = async ({ columns: ['ROWID'], valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN], hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN], + writableColumns, readOnly: false, }; } else { diff --git a/frontend/src/utils/rowLocator.ts b/frontend/src/utils/rowLocator.ts index f4be8a8..7196d47 100644 --- a/frontend/src/utils/rowLocator.ts +++ b/frontend/src/utils/rowLocator.ts @@ -11,6 +11,7 @@ export type EditRowLocator = { columns: string[]; valueColumns: string[]; hiddenColumns?: string[]; + writableColumns?: Record; readOnly: boolean; reason?: string; }; @@ -131,3 +132,21 @@ export const isHiddenLocatorColumn = (column: string, locator?: EditRowLocator): const normalized = normalizeColumnName(column).toLowerCase(); return (locator?.hiddenColumns || []).some((hidden) => normalizeColumnName(hidden).toLowerCase() === normalized); }; + +export const resolveWritableColumnName = (column: string, locator?: EditRowLocator): string | undefined => { + const normalized = normalizeColumnName(column); + if (!normalized || isHiddenLocatorColumn(normalized, locator)) return undefined; + const writableColumns = locator?.writableColumns; + if (!writableColumns) return normalized; + + const normalizedTarget = normalized.toLowerCase(); + const matchedEntry = Object.entries(writableColumns).find(([resultColumn]) => ( + normalizeColumnName(resultColumn).toLowerCase() === normalizedTarget + )); + const tableColumnName = normalizeColumnName(matchedEntry?.[1] || ''); + return tableColumnName || undefined; +}; + +export const isWritableResultColumn = (column: string, locator?: EditRowLocator): boolean => ( + resolveWritableColumnName(column, locator) !== undefined +);