feat(data-grid): 支持无主键表安全编辑

- 定位策略:新增主键、唯一索引和 Oracle ROWID 三类安全行定位能力
- 查询编辑器:简单单表 SELECT 自动补充隐藏定位列,复杂结果保持只读
- 表预览:无主键表可通过唯一索引或 Oracle ROWID 安全编辑
- 提交流程:移除无主键整行 WHERE fallback,隐藏定位列不参与展示和写入
- 后端保护:Oracle、MySQL、PostgreSQL 更新删除必须恰好影响 1 行
- 测试覆盖:补充 QueryEditor、DataViewer、DataGrid 和 ApplyChanges 相关用例
Refs #419
This commit is contained in:
Syngnat
2026-04-29 12:33:35 +08:00
parent 05a913ccb2
commit b1ef52f62e
18 changed files with 1823 additions and 228 deletions

View File

@@ -0,0 +1,133 @@
import type { IndexDefinition } from '../types';
import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyInsert';
import { isOracleLikeDialect } from './sqlDialect';
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'none';
export type EditRowLocator = {
strategy: RowLocatorStrategy;
columns: string[];
valueColumns: string[];
hiddenColumns?: string[];
readOnly: boolean;
reason?: string;
};
export type ResolveEditRowLocatorParams = {
dbType: string;
resultColumns: string[];
primaryKeys?: string[];
indexes?: IndexDefinition[];
allowOracleRowID?: boolean;
};
export type ResolveRowLocatorValuesResult =
| { ok: true; values: Record<string, any> }
| { ok: false; error: string };
const normalizeColumnName = (value: string): string => String(value || '').trim();
const hasColumn = (columns: string[], target: string): boolean => {
const normalizedTarget = normalizeColumnName(target).toLowerCase();
return columns.some((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget);
};
const findColumn = (columns: string[], target: string): string => {
const normalizedTarget = normalizeColumnName(target).toLowerCase();
return columns.find((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget) || target;
};
const buildReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
export const resolveEditRowLocator = ({
dbType,
resultColumns,
primaryKeys = [],
indexes,
allowOracleRowID = false,
}: ResolveEditRowLocatorParams): EditRowLocator => {
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
if (primaryKeyColumns.length > 0) {
const missing = primaryKeyColumns.filter((column) => !hasColumn(columns, column));
if (missing.length === 0) {
return {
strategy: 'primary-key',
columns: primaryKeyColumns,
valueColumns: primaryKeyColumns.map((column) => findColumn(columns, column)),
readOnly: false,
};
}
return buildReadOnlyLocator(`结果集中缺少主键列 ${missing.join(', ')},无法安全提交修改。`);
}
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0 && group.every((column) => hasColumn(columns, column)));
if (uniqueKeyGroup) {
return {
strategy: 'unique-key',
columns: uniqueKeyGroup,
valueColumns: uniqueKeyGroup.map((column) => findColumn(columns, column)),
readOnly: false,
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType) && hasColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN)) {
const rowIDColumn = findColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN);
return {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [rowIDColumn],
hiddenColumns: [rowIDColumn],
readOnly: false,
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID无法安全提交修改。');
}
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
};
export const resolveRowLocatorValues = (
locator: EditRowLocator | undefined,
row: Record<string, any>,
): ResolveRowLocatorValuesResult => {
if (!locator || locator.readOnly || locator.strategy === 'none') {
return { ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' };
}
const values: Record<string, any> = {};
for (let index = 0; index < locator.columns.length; index++) {
const column = locator.columns[index];
const valueColumn = locator.valueColumns[index] || column;
const value = row?.[valueColumn];
if (value === null || value === undefined || value === '') {
return { ok: false, error: `定位列 ${column} 的值为空,无法安全提交修改。` };
}
values[column] = value;
}
return { ok: true, values };
};
export const filterHiddenLocatorColumns = (columns: string[], locator?: EditRowLocator): string[] => {
const hidden = new Set((locator?.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase()));
if (hidden.size === 0) return columns;
return (columns || []).filter((column) => !hidden.has(normalizeColumnName(column).toLowerCase()));
};
export const isHiddenLocatorColumn = (column: string, locator?: EditRowLocator): boolean => {
const normalized = normalizeColumnName(column).toLowerCase();
return (locator?.hiddenColumns || []).some((hidden) => normalizeColumnName(hidden).toLowerCase() === normalized);
};