feat(data-grid): 无主键表支持全列匹配编辑并移除只读限制

- 新增 all-columns 全列匹配行定位策略,无主键/唯一索引时不再强制只读
- 元数据加载失败、索引查询失败等误报场景全部降级为全列匹配编辑
- 修复 resolveQueryLocatorPlan 同步解析异常导致 SQL 结果页签不出现的问题
- 行安全由后端影响行数校验兜底,非单行命中即回滚整个事务
- 同步更新六语言文案与相关测试用例
This commit is contained in:
Syngnat
2026-07-05 16:00:25 +08:00
parent 8280f342a8
commit 5beb4c3ce4
14 changed files with 275 additions and 288 deletions

View File

@@ -68,36 +68,35 @@ describe('resolveEditRowLocator', () => {
});
});
it('ignores non-unique indexes', () => {
it('falls back to all-columns matching when only non-unique indexes exist', () => {
expect(resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['NAME'],
indexes: [normalIndex('idx_name', 'NAME')],
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'No primary key or usable unique index was found, so changes cannot be submitted safely.',
strategy: 'all-columns',
columns: ['NAME'],
valueColumns: ['NAME'],
readOnly: false,
reason: 'No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.',
});
});
it('keeps results read-only when primary key columns are missing from result columns', () => {
it('falls back to all-columns matching when primary key columns are missing from result columns', () => {
expect(resolveEditRowLocator({
dbType: 'oracle',
resultColumns: ['NAME'],
primaryKeys: ['ID'],
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'The result set is missing primary key column ID, so changes cannot be submitted safely.',
strategy: 'all-columns',
columns: ['NAME'],
readOnly: false,
});
});
it('localizes read-only reasons while preserving raw locator names', () => {
const translate = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => ({
'data_viewer.read_only.reason.primary_key_column_missing': `結果集中缺少主鍵欄位 ${params?.columns},無法安全提交修改。`,
'data_viewer.read_only.reason.no_safe_locator': '未偵測到主鍵或可用唯一索引,無法安全提交修改。',
'data_viewer.read_only.reason.oracle_rowid_missing': '未偵測到主鍵或可用唯一索引,且結果集中缺少 Oracle ROWID無法安全提交修改。',
'data_viewer.read_only.reason.duckdb_rowid_missing': '未偵測到主鍵、可用唯一索引或 DuckDB rowid無法安全提交修改。',
it('localizes the all-columns hint through the provided translator', () => {
const translate = (key: string) => ({
'data_viewer.edit_hint.all_columns_locator': '未偵測到主鍵或唯一索引,將使用全欄位匹配定位資料列,請謹慎編輯。',
}[key] ?? key);
expect(resolveEditRowLocator({
@@ -106,9 +105,9 @@ describe('resolveEditRowLocator', () => {
primaryKeys: ['TENANT_ID', 'ID'],
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '結果集中缺少主鍵欄位 TENANT_ID, ID無法安全提交修改。',
strategy: 'all-columns',
readOnly: false,
reason: '未偵測到主鍵或唯一索引,將使用全欄位匹配定位資料列,請謹慎編輯。',
});
expect(resolveEditRowLocator({
@@ -117,9 +116,9 @@ describe('resolveEditRowLocator', () => {
allowOracleRowID: true,
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未偵測到主鍵或可用唯一索引,且結果集中缺少 Oracle ROWID無法安全提交修改。',
strategy: 'all-columns',
columns: ['NAME'],
readOnly: false,
});
expect(resolveEditRowLocator({
@@ -128,9 +127,9 @@ describe('resolveEditRowLocator', () => {
allowDuckDBRowID: true,
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未偵測到主鍵、可用唯一索引或 DuckDB rowid無法安全提交修改。',
strategy: 'all-columns',
columns: ['name'],
readOnly: false,
});
});
@@ -213,6 +212,70 @@ describe('resolveRowLocatorValues', () => {
values: { rowid: 17 },
});
});
it('collects non-empty scalar values for all-columns locators', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['ID', 'NAME', 'NOTE', 'PAYLOAD'],
});
expect(locator.strategy).toBe('all-columns');
expect(resolveRowLocatorValues(locator, {
ID: 7,
NAME: 'A',
NOTE: null,
PAYLOAD: { nested: true },
})).toEqual({
ok: true,
values: { ID: 7, NAME: 'A' },
});
});
it('skips oversized string values for all-columns locators', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['ID', 'BLOB_TEXT'],
});
expect(resolveRowLocatorValues(locator, {
ID: 1,
BLOB_TEXT: 'x'.repeat(5000),
})).toEqual({
ok: true,
values: { ID: 1 },
});
});
it('falls back to row keys when an all-columns locator has no columns', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: [],
});
expect(locator.strategy).toBe('all-columns');
expect(resolveRowLocatorValues(locator, {
ID: 3,
NAME: 'B',
__gonavi_row_key__: 'internal',
})).toEqual({
ok: true,
values: { ID: 3, NAME: 'B' },
});
});
it('rejects all-columns rows without any usable locator value', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['NOTE'],
});
expect(resolveRowLocatorValues(locator, { NOTE: null }, {
noSafeLocator: () => 'No usable locator values.',
})).toEqual({
ok: false,
error: 'No usable locator values.',
});
});
});
describe('filterHiddenLocatorColumns', () => {

View File

@@ -6,7 +6,7 @@ import { t as translateCatalog, type I18nParams } from '../i18n';
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
export const DUCKDB_ROWID_LOCATOR_COLUMN = '__gonavi_duckdb_rowid__';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'duckdb-rowid' | 'none';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'duckdb-rowid' | 'all-columns' | 'none';
export type EditRowLocator = {
strategy: RowLocatorStrategy;
@@ -51,20 +51,46 @@ const findColumn = (columns: string[], target: string): string => {
return columns.find((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget) || target;
};
const buildReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
const GONAVI_INTERNAL_COLUMN_PREFIX = '__gonavi';
const ROW_LOCATOR_REASON_KEYS = {
noSafeLocator: 'data_viewer.read_only.reason.no_safe_locator',
oracleRowIDMissing: 'data_viewer.read_only.reason.oracle_rowid_missing',
duckDBRowIDMissing: 'data_viewer.read_only.reason.duckdb_rowid_missing',
primaryKeyColumnMissing: 'data_viewer.read_only.reason.primary_key_column_missing',
} as const;
// Values longer than this are excluded from all-columns locators because
// LOB-typed columns cannot be compared with "=" on several dialects (Oracle).
const ALL_COLUMNS_LOCATOR_MAX_VALUE_LENGTH = 4000;
const ALL_COLUMNS_LOCATOR_HINT_KEY = 'data_viewer.edit_hint.all_columns_locator';
const isInternalLocatorColumn = (column: string): boolean => (
normalizeColumnName(column).toLowerCase().startsWith(GONAVI_INTERNAL_COLUMN_PREFIX)
);
const isScalarLocatorValue = (value: any): boolean => (
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint'
);
export const buildAllColumnsLocator = (
resultColumns: string[],
options?: {
hiddenColumns?: string[];
writableColumns?: Record<string, string>;
translate?: RowLocatorTranslator;
},
): EditRowLocator => {
const hidden = new Set((options?.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase()));
const columns = (resultColumns || [])
.map(normalizeColumnName)
.filter(Boolean)
.filter((column) => !isInternalLocatorColumn(column))
.filter((column) => !hidden.has(column.toLowerCase()));
return {
strategy: 'all-columns',
columns,
valueColumns: [...columns],
hiddenColumns: options?.hiddenColumns,
writableColumns: options?.writableColumns,
readOnly: false,
reason: translateReason(options?.translate, ALL_COLUMNS_LOCATOR_HINT_KEY),
};
};
const translateReason = (
translate: RowLocatorTranslator | undefined,
@@ -98,11 +124,7 @@ export const resolveEditRowLocator = ({
readOnly: false,
};
}
return buildReadOnlyLocator(translateReason(
translate,
ROW_LOCATOR_REASON_KEYS.primaryKeyColumnMissing,
{ columns: missing.join(', ') },
));
return buildAllColumnsLocator(columns, { translate });
}
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
@@ -138,15 +160,40 @@ export const resolveEditRowLocator = ({
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.oracleRowIDMissing));
}
return buildAllColumnsLocator(columns, { translate });
};
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb') {
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.duckDBRowIDMissing));
}
const resolveAllColumnsLocatorValues = (
locator: EditRowLocator,
row: Record<string, any>,
messages?: RowLocatorMessages,
): ResolveRowLocatorValuesResult => {
const hidden = new Set((locator.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase()));
const rowKeys = Object.keys(row || {});
const rowKeyByLower = new Map<string, string>();
rowKeys.forEach((key) => rowKeyByLower.set(normalizeColumnName(key).toLowerCase(), key));
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.noSafeLocator));
const candidateColumns = (locator.columns.length > 0 ? locator.columns : rowKeys)
.map(normalizeColumnName)
.filter(Boolean)
.filter((column) => !isInternalLocatorColumn(column))
.filter((column) => !hidden.has(column.toLowerCase()));
const values: Record<string, any> = {};
candidateColumns.forEach((column) => {
const rowKey = rowKeyByLower.get(column.toLowerCase());
if (rowKey === undefined) return;
const value = row?.[rowKey];
if (value === null || value === undefined || value === '') return;
if (!isScalarLocatorValue(value)) return;
if (typeof value === 'string' && value.length > ALL_COLUMNS_LOCATOR_MAX_VALUE_LENGTH) return;
values[column] = value;
});
if (Object.keys(values).length === 0) {
return { ok: false, error: messages?.noSafeLocator?.() || 'No usable locator values were found on this row, so changes cannot be submitted safely.' };
}
return { ok: true, values };
};
export const resolveRowLocatorValues = (
@@ -158,6 +205,10 @@ export const resolveRowLocatorValues = (
return { ok: false, error: messages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' };
}
if (locator.strategy === 'all-columns') {
return resolveAllColumnsLocatorValues(locator, row, messages);
}
const values: Record<string, any> = {};
for (let index = 0; index < locator.columns.length; index++) {
const column = locator.columns[index];