mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 20:35:14 +08:00
✨ feat(data-grid): 无主键表支持全列匹配编辑并移除只读限制
- 新增 all-columns 全列匹配行定位策略,无主键/唯一索引时不再强制只读 - 元数据加载失败、索引查询失败等误报场景全部降级为全列匹配编辑 - 修复 resolveQueryLocatorPlan 同步解析异常导致 SQL 结果页签不出现的问题 - 行安全由后端影响行数校验兜底,非单行命中即回滚整个事务 - 同步更新六语言文案与相关测试用例
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 语句注入行数限制(防止大结果集卡死)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,6 +65,9 @@ const readQueryEditorResultsPanelSource = (): string =>
|
||||
const readSqlDialectSource = (): string =>
|
||||
readFileSync(new URL("../utils/sqlDialect.ts", import.meta.url), "utf8");
|
||||
|
||||
const readRowLocatorSource = (): string =>
|
||||
readFileSync(new URL("../utils/rowLocator.ts", import.meta.url), "utf8");
|
||||
|
||||
const sliceBetween = (source: string, start: string, end: string): string => {
|
||||
const startIndex = source.indexOf(start);
|
||||
const endIndex = source.indexOf(end, startIndex + start.length);
|
||||
@@ -1831,157 +1834,31 @@ describe("i18n catalog", () => {
|
||||
assertSourceDoesNotInlineCatalogValues(databaseSuggestionDetailSource, [databaseLabelKey]);
|
||||
});
|
||||
|
||||
it("keeps QueryEditor no-safe-locator read-only warning copy in catalogs instead of source literals", () => {
|
||||
const noSafeLocatorReasonKey = "query_editor.message.read_only_no_safe_locator" as const;
|
||||
const readOnlyWarningWrapperKey = "query_editor.message.read_only_warning_with_detail" as const;
|
||||
const source = readQueryEditorHelpersSource();
|
||||
const noSafeLocatorSource = sliceBetween(
|
||||
source,
|
||||
" if (!resIndexes?.success) {",
|
||||
" const executableAppendExpressions = [",
|
||||
it("keeps the all-columns edit hint in catalogs instead of source literals", () => {
|
||||
const allColumnsHintKey = "data_viewer.edit_hint.all_columns_locator" as const;
|
||||
const rowLocatorSource = readRowLocatorSource();
|
||||
const helpersSource = readQueryEditorHelpersSource();
|
||||
const buildAllColumnsLocatorSource = sliceBetween(
|
||||
rowLocatorSource,
|
||||
"export const buildAllColumnsLocator = (",
|
||||
"export const resolveEditRowLocator = ({",
|
||||
);
|
||||
|
||||
for (const language of SUPPORTED_LANGUAGES) {
|
||||
expect(catalogs[language]).toHaveProperty(noSafeLocatorReasonKey);
|
||||
expect(catalogs[language][noSafeLocatorReasonKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][noSafeLocatorReasonKey])).toEqual([]);
|
||||
|
||||
expect(catalogs[language]).toHaveProperty(readOnlyWarningWrapperKey);
|
||||
expect(catalogs[language][readOnlyWarningWrapperKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][readOnlyWarningWrapperKey])).toEqual(["detail"]);
|
||||
expect(catalogs[language]).toHaveProperty(allColumnsHintKey);
|
||||
expect(catalogs[language][allColumnsHintKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][allColumnsHintKey])).toEqual([]);
|
||||
}
|
||||
|
||||
expect(t("zh-CN", noSafeLocatorReasonKey)).toBe("未检测到主键或可用唯一索引,无法安全提交修改。");
|
||||
expect(t("en-US", noSafeLocatorReasonKey)).toBe("No primary key or usable unique index was detected, so changes cannot be committed safely.");
|
||||
expect(t("zh-CN", readOnlyWarningWrapperKey, { detail: "main.users 未检测到主键或可用唯一索引,无法安全提交修改。" }))
|
||||
.toBe("查询结果保持只读:main.users 未检测到主键或可用唯一索引,无法安全提交修改。");
|
||||
expect(t("en-US", readOnlyWarningWrapperKey, { detail: "main.users No primary key or usable unique index was detected, so changes cannot be committed safely." }))
|
||||
.toBe("Query results remain read-only: main.users No primary key or usable unique index was detected, so changes cannot be committed safely.");
|
||||
expect(t("zh-CN", allColumnsHintKey)).toBe("未检测到主键或唯一索引,将使用全列匹配定位行,请谨慎编辑。");
|
||||
expect(t("en-US", allColumnsHintKey)).toBe("No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.");
|
||||
|
||||
expect(noSafeLocatorSource).toContain(noSafeLocatorReasonKey);
|
||||
expect(noSafeLocatorSource).toContain(readOnlyWarningWrapperKey);
|
||||
expect(noSafeLocatorSource).not.toContain("未检测到主键或可用唯一索引,无法安全提交修改。");
|
||||
expect(rowLocatorSource).toContain(allColumnsHintKey);
|
||||
expect(buildAllColumnsLocatorSource).toContain("ALL_COLUMNS_LOCATOR_HINT_KEY");
|
||||
expect(helpersSource).toContain("buildAllColumnsLocator");
|
||||
|
||||
assertSourceDoesNotInlineCatalogValues(noSafeLocatorSource, [noSafeLocatorReasonKey, readOnlyWarningWrapperKey]);
|
||||
});
|
||||
|
||||
it("keeps QueryEditor index-metadata-unavailable read-only warning copy in catalogs instead of source literals", () => {
|
||||
const indexMetadataUnavailableReasonKey = "query_editor.message.read_only_index_metadata_unavailable" as const;
|
||||
const readOnlyWarningWrapperKey = "query_editor.message.read_only_warning_with_detail" as const;
|
||||
const source = readQueryEditorHelpersSource();
|
||||
const indexMetadataUnavailableSource = sliceBetween(
|
||||
source,
|
||||
" if (!resIndexes?.success) {",
|
||||
" const executableAppendExpressions = [",
|
||||
);
|
||||
|
||||
for (const language of SUPPORTED_LANGUAGES) {
|
||||
expect(catalogs[language]).toHaveProperty(indexMetadataUnavailableReasonKey);
|
||||
expect(catalogs[language][indexMetadataUnavailableReasonKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][indexMetadataUnavailableReasonKey])).toEqual([]);
|
||||
|
||||
expect(catalogs[language]).toHaveProperty(readOnlyWarningWrapperKey);
|
||||
expect(catalogs[language][readOnlyWarningWrapperKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][readOnlyWarningWrapperKey])).toEqual(["detail"]);
|
||||
}
|
||||
|
||||
expect(t("zh-CN", indexMetadataUnavailableReasonKey)).toBe("无法加载唯一索引元数据,无法安全提交修改。");
|
||||
expect(t("en-US", indexMetadataUnavailableReasonKey)).toBe("Unable to load unique index metadata, so changes cannot be committed safely.");
|
||||
expect(t("zh-CN", readOnlyWarningWrapperKey, { detail: "main.users 无法加载唯一索引元数据,无法安全提交修改。" }))
|
||||
.toBe("查询结果保持只读:main.users 无法加载唯一索引元数据,无法安全提交修改。");
|
||||
expect(t("en-US", readOnlyWarningWrapperKey, { detail: "main.users Unable to load unique index metadata, so changes cannot be committed safely." }))
|
||||
.toBe("Query results remain read-only: main.users Unable to load unique index metadata, so changes cannot be committed safely.");
|
||||
|
||||
expect(indexMetadataUnavailableSource).toContain(indexMetadataUnavailableReasonKey);
|
||||
expect(indexMetadataUnavailableSource).toContain(readOnlyWarningWrapperKey);
|
||||
expect(indexMetadataUnavailableSource).not.toContain("无法加载唯一索引元数据,无法安全提交修改。");
|
||||
|
||||
assertSourceDoesNotInlineCatalogValues(indexMetadataUnavailableSource, [indexMetadataUnavailableReasonKey, readOnlyWarningWrapperKey]);
|
||||
});
|
||||
|
||||
it("keeps QueryEditor table-locator-metadata-unavailable read-only warning copy in catalogs instead of source literals", () => {
|
||||
const tableLocatorMetadataUnavailableReasonKey = "query_editor.message.read_only_table_locator_metadata_unavailable" as const;
|
||||
const readOnlyWarningWrapperKey = "query_editor.message.read_only_warning_with_detail" as const;
|
||||
const source = readQueryEditorHelpersSource();
|
||||
const resolveQueryLocatorPlanSource = sliceBetween(
|
||||
source,
|
||||
"export const resolveQueryLocatorPlan = async ({",
|
||||
"\n};",
|
||||
);
|
||||
const resColsFailureSource = sliceBetween(
|
||||
resolveQueryLocatorPlanSource,
|
||||
" if (!resCols?.success || !Array.isArray(resCols.data)) {",
|
||||
" return plan;",
|
||||
);
|
||||
const catchFailureSource = sliceBetween(
|
||||
resolveQueryLocatorPlanSource,
|
||||
" } catch {",
|
||||
" return plan;",
|
||||
);
|
||||
|
||||
for (const language of SUPPORTED_LANGUAGES) {
|
||||
expect(catalogs[language]).toHaveProperty(tableLocatorMetadataUnavailableReasonKey);
|
||||
expect(catalogs[language][tableLocatorMetadataUnavailableReasonKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][tableLocatorMetadataUnavailableReasonKey])).toEqual(["table"]);
|
||||
|
||||
expect(catalogs[language]).toHaveProperty(readOnlyWarningWrapperKey);
|
||||
expect(catalogs[language][readOnlyWarningWrapperKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][readOnlyWarningWrapperKey])).toEqual(["detail"]);
|
||||
}
|
||||
|
||||
expect(t("zh-CN", tableLocatorMetadataUnavailableReasonKey, { table: "main.users" }))
|
||||
.toBe("无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。");
|
||||
expect(t("en-US", tableLocatorMetadataUnavailableReasonKey, { table: "main.users" }))
|
||||
.toBe("Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.");
|
||||
expect(t("zh-CN", readOnlyWarningWrapperKey, { detail: "无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。" }))
|
||||
.toBe("查询结果保持只读:无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。");
|
||||
expect(t("en-US", readOnlyWarningWrapperKey, { detail: "Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely." }))
|
||||
.toBe("Query results remain read-only: Unable to load primary key/unique index metadata for main.users, so changes cannot be committed safely.");
|
||||
|
||||
expect(resColsFailureSource).toContain(tableLocatorMetadataUnavailableReasonKey);
|
||||
expect(resColsFailureSource).toContain(readOnlyWarningWrapperKey);
|
||||
expect(resColsFailureSource).not.toContain("无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。");
|
||||
|
||||
expect(catchFailureSource).toContain(tableLocatorMetadataUnavailableReasonKey);
|
||||
expect(catchFailureSource).toContain(readOnlyWarningWrapperKey);
|
||||
expect(catchFailureSource).not.toContain("无法加载 main.users 的主键/唯一索引元数据,无法安全提交修改。");
|
||||
|
||||
assertSourceDoesNotInlineCatalogValues(resColsFailureSource, [tableLocatorMetadataUnavailableReasonKey, readOnlyWarningWrapperKey]);
|
||||
assertSourceDoesNotInlineCatalogValues(catchFailureSource, [tableLocatorMetadataUnavailableReasonKey, readOnlyWarningWrapperKey]);
|
||||
});
|
||||
|
||||
it("keeps QueryEditor Oracle ROWID fallback read-only warning copy in catalogs instead of source literals", () => {
|
||||
const oracleRowIdFallbackReasonKey = "query_editor.message.read_only_oracle_rowid_injection_unavailable" as const;
|
||||
const readOnlyWarningWrapperKey = "query_editor.message.read_only_warning_with_detail" as const;
|
||||
const source = readQueryEditorHelpersSource();
|
||||
const oracleRowIdFallbackSource = sliceBetween(
|
||||
source,
|
||||
" if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) {",
|
||||
" plan.executedSql = appendQuerySelectExpressions(executableStatement, executableAppendExpressions);",
|
||||
);
|
||||
|
||||
for (const language of SUPPORTED_LANGUAGES) {
|
||||
expect(catalogs[language]).toHaveProperty(oracleRowIdFallbackReasonKey);
|
||||
expect(catalogs[language][oracleRowIdFallbackReasonKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][oracleRowIdFallbackReasonKey])).toEqual([]);
|
||||
|
||||
expect(catalogs[language]).toHaveProperty(readOnlyWarningWrapperKey);
|
||||
expect(catalogs[language][readOnlyWarningWrapperKey]).toBeTruthy();
|
||||
expect(getPlaceholders(catalogs[language][readOnlyWarningWrapperKey])).toEqual(["detail"]);
|
||||
}
|
||||
|
||||
expect(t("zh-CN", oracleRowIdFallbackReasonKey)).toBe("Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。");
|
||||
expect(t("en-US", oracleRowIdFallbackReasonKey)).toBe("The Oracle query uses *, so the ROWID locator column could not be injected automatically. Results remain read-only.");
|
||||
expect(t("zh-CN", readOnlyWarningWrapperKey, { detail: "Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。" }))
|
||||
.toBe("查询结果保持只读:Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。");
|
||||
expect(t("en-US", readOnlyWarningWrapperKey, { detail: "The Oracle query uses *, so the ROWID locator column could not be injected automatically. Results remain read-only." }))
|
||||
.toBe("Query results remain read-only: The Oracle query uses *, so the ROWID locator column could not be injected automatically. Results remain read-only.");
|
||||
|
||||
expect(oracleRowIdFallbackSource).toContain(oracleRowIdFallbackReasonKey);
|
||||
expect(oracleRowIdFallbackSource).toContain(readOnlyWarningWrapperKey);
|
||||
expect(oracleRowIdFallbackSource).not.toContain("Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。");
|
||||
|
||||
assertSourceDoesNotInlineCatalogValues(oracleRowIdFallbackSource, [oracleRowIdFallbackReasonKey, readOnlyWarningWrapperKey]);
|
||||
assertSourceDoesNotInlineCatalogValues(rowLocatorSource, [allColumnsHintKey]);
|
||||
assertSourceDoesNotInlineCatalogValues(helpersSource, [allColumnsHintKey]);
|
||||
});
|
||||
|
||||
it("keeps QueryEditor AI context menu labels in catalogs instead of source literals", () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user