mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +08:00
✨ feat(query-editor): 放宽单表查询结果列级编辑边界
- 查询编辑:支持简单表列与表达式列混合展示 - 编辑安全:仅允许真实表列编辑,表达式列保持只读 - 提交流程:支持结果列别名映射回真实表字段 - 测试覆盖:补充聚合查询静默只读与列级提交用例
This commit is contained in:
@@ -1 +1 @@
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user