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

@@ -35,6 +35,7 @@ const backendApp = vi.hoisted(() => ({
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
@@ -642,7 +643,7 @@ describe('DataViewer safe editing locator', () => {
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
it('falls back to all-columns editing when no safe locator exists', async () => {
storeState.languagePreference = 'en-US';
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
@@ -655,12 +656,13 @@ describe('DataViewer safe editing locator', () => {
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).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: ['ID', 'NAME'],
readOnly: false,
reason: 'No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('Table main.users remains read-only: No primary key or usable unique index was found, so changes cannot be submitted safely.');
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.info).toHaveBeenCalledWith('No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.');
renderer.unmount();
});
});

View File

@@ -19,6 +19,7 @@ import {
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
buildAllColumnsLocator,
resolveEditRowLocator,
type EditRowLocator,
} from '../utils/rowLocator';
@@ -652,12 +653,12 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (pkKeyRef.current !== locatorKey) return;
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
const nextLocator = buildAllColumnsLocator([], { translate: tr });
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
if (nextLocator.reason) message.info(nextLocator.reason);
} else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
@@ -673,7 +674,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
: (String(dbType || '').trim().toLowerCase() === 'duckdb'
? [...resultColumns, DUCKDB_ROWID_LOCATOR_COLUMN]
: resultColumns);
let nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({
const nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({
dbType,
resultColumns: locatorColumns,
primaryKeys,
@@ -683,28 +684,26 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
translate: tr,
}), tr);
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.index_metadata_unavailable'));
}
pkColumnsForQuery = primaryKeys;
editLocatorForQuery = nextLocator;
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
} else if (nextLocator.strategy === 'all-columns' && nextLocator.reason) {
message.info(nextLocator.reason);
}
}
} catch {
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
const nextLocator = buildAllColumnsLocator([], { translate: tr });
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
if (nextLocator.reason) message.info(nextLocator.reason);
}
}
}

View File

@@ -8593,7 +8593,7 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'master' })]));
});
it('localizes the non-Oracle no-safe-locator read-only warning in English while preserving the raw table name', async () => {
it('falls back to all-columns editing when no safe locator exists for non-Oracle results', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
@@ -8621,12 +8621,12 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'No primary key or usable unique index was detected, so changes cannot be committed safely.',
strategy: 'all-columns',
readOnly: false,
reason: 'No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith(
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: main.users No primary key or usable unique index was detected, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(
@@ -8634,7 +8634,7 @@ describe('QueryEditor external SQL save', () => {
);
});
it('localizes the non-Oracle index-metadata-unavailable read-only warning in English while preserving the raw table name', async () => {
it('falls back to all-columns editing when unique index metadata is unavailable', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
@@ -8665,12 +8665,11 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'Unable to load unique index metadata, so changes cannot be committed safely.',
strategy: 'all-columns',
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith(
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: main.users Unable to load unique index metadata, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(
@@ -8678,7 +8677,7 @@ describe('QueryEditor external SQL save', () => {
);
});
it('localizes the table-locator-metadata-unavailable read-only warning in English while preserving the raw table name', async () => {
it('falls back to all-columns editing when table locator metadata is unavailable', async () => {
storeState.languagePreference = 'en-US';
setCurrentLanguage('en-US');
backendApp.DBQueryMulti.mockResolvedValueOnce({
@@ -8705,12 +8704,12 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.',
strategy: 'all-columns',
columns: [],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith(
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalledWith(
'Query results remain read-only: Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.',
);
expect(messageApi.warning).not.toHaveBeenCalledWith(

View File

@@ -6092,14 +6092,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
const statementPlans: QueryStatementPlan[] = [];
for (let index = 0; index < sourceStatements.length; index += 1) {
statementPlans.push(await resolveQueryLocatorPlan({
statement: executedSourceStatements[index] || sourceStatements[index],
originalStatement: sourceStatements[index],
dbType: normalizedDbType,
currentDb,
config,
forceReadOnly: forceReadOnlyResult,
}));
const statementForPlan = executedSourceStatements[index] || sourceStatements[index];
try {
statementPlans.push(await resolveQueryLocatorPlan({
statement: statementForPlan,
originalStatement: sourceStatements[index],
dbType: normalizedDbType,
currentDb,
config,
forceReadOnly: forceReadOnlyResult,
}));
} catch (planError) {
// 行定位计划失败绝不能阻断查询执行,兜底裸计划保证结果页始终呈现。
console.warn('resolveQueryLocatorPlan failed; falling back to a bare statement plan', planError);
statementPlans.push({
originalSql: sourceStatements[index],
executedSql: statementForPlan,
pkColumns: [],
});
}
}
// 自动给 SELECT 语句注入行数限制(防止大结果集卡死)

View File

@@ -11,6 +11,7 @@ import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload } from
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
buildAllColumnsLocator,
type EditRowLocator,
} from '../../utils/rowLocator';
import { getQueryTabDraft, hasQueryTabDraft } from '../../utils/sqlFileTabDrafts';
@@ -2226,35 +2227,35 @@ export const resolveQueryLocatorPlan = async ({
};
if (forceReadOnly) return plan;
const defaultSchema = isOracleLikeDialect(dbType)
? resolveOracleLikeExecutionSchemaName(config, currentDb)
: '';
let tableRef = extractQueryResultTableRef(statement, dbType, currentDb, defaultSchema);
if (!tableRef) return plan;
plan.tableRef = tableRef;
if (isSystemMetadataQueryResult(tableRef, dbType)) {
plan.editLocator = buildQueryReadOnlyLocator(translate('query_editor.message.read_only_system_metadata'));
return plan;
}
const selectInfo = parseSimpleSelectInfo(statement);
if (!selectInfo) {
// 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。
return plan;
}
if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) {
return plan;
}
if (isOracleLikeDialect(dbType) && defaultSchema && !String(tableRef.tableName || '').includes('.')) {
tableRef = {
...tableRef,
tableName: `${tableRef.metadataDbName}.${tableRef.metadataTableName}`,
};
plan.tableRef = tableRef;
}
try {
const defaultSchema = isOracleLikeDialect(dbType)
? resolveOracleLikeExecutionSchemaName(config, currentDb)
: '';
let tableRef = extractQueryResultTableRef(statement, dbType, currentDb, defaultSchema);
if (!tableRef) return plan;
plan.tableRef = tableRef;
if (isSystemMetadataQueryResult(tableRef, dbType)) {
plan.editLocator = buildQueryReadOnlyLocator(translate('query_editor.message.read_only_system_metadata'));
return plan;
}
const selectInfo = parseSimpleSelectInfo(statement);
if (!selectInfo) {
// 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。
return plan;
}
if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) {
return plan;
}
if (isOracleLikeDialect(dbType) && defaultSchema && !String(tableRef.tableName || '').includes('.')) {
tableRef = {
...tableRef,
tableName: `${tableRef.metadataDbName}.${tableRef.metadataTableName}`,
};
plan.tableRef = tableRef;
}
const [resCols, resIndexes] = await Promise.all([
withSoftTimeout(
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
@@ -2267,11 +2268,7 @@ export const resolveQueryLocatorPlan = async ({
),
]);
if (!resCols?.success || !Array.isArray(resCols.data)) {
const reason = translate('query_editor.message.read_only_table_locator_metadata_unavailable', {
table: `${tableRef.metadataDbName}.${tableRef.metadataTableName}`,
});
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = translate('query_editor.message.read_only_warning_with_detail', { detail: reason });
plan.editLocator = buildAllColumnsLocator([], { translate });
return plan;
}
@@ -2349,19 +2346,7 @@ export const resolveQueryLocatorPlan = async ({
readOnly: false,
};
} else {
if (!resIndexes?.success) {
const reason = translate('query_editor.message.read_only_index_metadata_unavailable');
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = translate('query_editor.message.read_only_warning_with_detail', {
detail: `${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`,
});
} else {
const reason = translate('query_editor.message.read_only_no_safe_locator');
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = translate('query_editor.message.read_only_warning_with_detail', {
detail: `${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`,
});
}
plan.editLocator = buildAllColumnsLocator(tableColumnNames, { writableColumns, translate });
}
}
@@ -2378,20 +2363,14 @@ export const resolveQueryLocatorPlan = async ({
return plan;
}
const reason = translate('query_editor.message.read_only_oracle_rowid_injection_unavailable');
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = translate('query_editor.message.read_only_warning_with_detail', { detail: reason });
plan.editLocator = buildAllColumnsLocator(tableColumnNames, { writableColumns, translate });
return plan;
}
plan.executedSql = appendQuerySelectExpressions(executableStatement, executableAppendExpressions);
return plan;
} catch {
const reason = translate('query_editor.message.read_only_table_locator_metadata_unavailable', {
table: `${tableRef.metadataDbName}.${tableRef.metadataTableName}`,
});
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = translate('query_editor.message.read_only_warning_with_detail', { detail: reason });
plan.editLocator = buildAllColumnsLocator([], { translate });
return plan;
}
};