feat(query-editor): 放宽单表查询结果列级编辑边界

- 查询编辑:支持简单表列与表达式列混合展示
- 编辑安全:仅允许真实表列编辑,表达式列保持只读
- 提交流程:支持结果列别名映射回真实表字段
- 测试覆盖:补充聚合查询静默只读与列级提交用例
This commit is contained in:
Syngnat
2026-04-29 20:07:22 +08:00
parent c927e33c8c
commit c1ebce4ef5
6 changed files with 193 additions and 22 deletions

View File

@@ -1 +1 @@
0295a42fd931778d85157816d79d29e5
d0464f9da25e9356e61652e638c99ffe

View File

@@ -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: [],

View File

@@ -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<string, any> = {};
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<DataGridProps> = ({
);
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<DataGridProps> = ({
}),
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) => (
<div style={CELL_ELLIPSIS_STYLE}>
{renderCellDisplayValue(text, normalizedPageFindText)}

View File

@@ -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(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
})} />);
});
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(<QueryEditor tab={createTab({
dbName: storeState.connections[0].config.database,
query: 'SELECT count(1) FROM users',
})} />);
});
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();
},
);
});

View File

@@ -203,7 +203,7 @@ const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
type SimpleSelectInfo = {
selectsAll: boolean;
resultColumns: string[];
writableColumns: Record<string, string>;
};
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<string, string> = {};
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<string, string>, 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<string, string> = 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 {

View File

@@ -11,6 +11,7 @@ export type EditRowLocator = {
columns: string[];
valueColumns: string[];
hiddenColumns?: string[];
writableColumns?: Record<string, string>;
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
);