From 5beb4c3ce41f7fbc2c60abb58eb64b3b2bc77f9d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 5 Jul 2026 16:00:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E6=97=A0?= =?UTF-8?q?=E4=B8=BB=E9=94=AE=E8=A1=A8=E6=94=AF=E6=8C=81=E5=85=A8=E5=88=97?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E7=BC=96=E8=BE=91=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 all-columns 全列匹配行定位策略,无主键/唯一索引时不再强制只读 - 元数据加载失败、索引查询失败等误报场景全部降级为全列匹配编辑 - 修复 resolveQueryLocatorPlan 同步解析异常导致 SQL 结果页签不出现的问题 - 行安全由后端影响行数校验兜底,非单行命中即回滚整个事务 - 同步更新六语言文案与相关测试用例 --- .../DataViewer.primary-key.test.tsx | 14 +- frontend/src/components/DataViewer.tsx | 17 +- .../QueryEditor.external-sql-save.test.tsx | 35 ++-- frontend/src/components/QueryEditor.tsx | 27 ++- .../queryEditor/QueryEditorHelpers.ts | 87 ++++----- frontend/src/i18n/catalog.test.ts | 165 +++--------------- frontend/src/utils/rowLocator.test.ts | 109 +++++++++--- frontend/src/utils/rowLocator.ts | 103 ++++++++--- shared/i18n/de-DE.json | 1 + shared/i18n/en-US.json | 1 + shared/i18n/ja-JP.json | 1 + shared/i18n/ru-RU.json | 1 + shared/i18n/zh-CN.json | 1 + shared/i18n/zh-TW.json | 1 + 14 files changed, 275 insertions(+), 288 deletions(-) diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index d8ff9583..68c0a0c4 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -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(); }); }); diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 0f7d2d24..8ec0d3e8 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -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); } } } diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 9df1edbd..2183cb9c 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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( diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 17aa7509..5cede146 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 语句注入行数限制(防止大结果集卡死) diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index cd6f1ad5..c14c17ac 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -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; } }; diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index 8183c22d..889725cd 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -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", () => { diff --git a/frontend/src/utils/rowLocator.test.ts b/frontend/src/utils/rowLocator.test.ts index aff900e8..5a70469d 100644 --- a/frontend/src/utils/rowLocator.test.ts +++ b/frontend/src/utils/rowLocator.test.ts @@ -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) => ({ - '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', () => { diff --git a/frontend/src/utils/rowLocator.ts b/frontend/src/utils/rowLocator.ts index 37fc7aa4..7e15a6ce 100644 --- a/frontend/src/utils/rowLocator.ts +++ b/frontend/src/utils/rowLocator.ts @@ -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; + 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, + messages?: RowLocatorMessages, +): ResolveRowLocatorValuesResult => { + const hidden = new Set((locator.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase())); + const rowKeys = Object.keys(row || {}); + const rowKeyByLower = new Map(); + 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 = {}; + 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 = {}; for (let index = 0; index < locator.columns.length; index++) { const column = locator.columns[index]; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index b14c9cb4..3a695270 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "Spalte {{column}} Typ {{type}} wurde zu {{targetType}} herabgestuft", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "Spalte {{column}} Typ {{type}} hat keine dedizierte TDengine-Zuordnung und wurde zu {{targetType}} herabgestuft", "data_sync.warning.tdengine_target_user_defined_type_fallback": "Spalte {{column}} ist ein benutzerdefinierter Typ und wurde zu {{targetType}} herabgestuft", + "data_viewer.edit_hint.all_columns_locator": "Kein Primärschlüssel oder eindeutiger Index erkannt. Zeilen werden durch Abgleich aller Spalten lokalisiert. Bitte mit Vorsicht bearbeiten.", "data_viewer.message.connection_not_found": "Verbindung nicht gefunden", "data_viewer.message.duckdb_query_timeout": "Die DuckDB-Abfrage hat das Verbindungstimeout überschritten und wurde unterbrochen. Erhöhen Sie das Verbindungstimeout oder verkleinern Sie den Sortier-/Filterbereich und versuchen Sie es erneut.", "data_viewer.message.fetch_data_failed_detail": "Fehler beim Abrufen der Daten: {{detail}}", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 5f197b86..ce4b74e1 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "Column {{column}} type {{type}} was degraded to {{targetType}}", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "Column {{column}} type {{type}} has no dedicated TDengine mapping and was degraded to {{targetType}}", "data_sync.warning.tdengine_target_user_defined_type_fallback": "Column {{column}} is a user-defined type and was degraded to {{targetType}}", + "data_viewer.edit_hint.all_columns_locator": "No primary key or unique index was detected, so rows will be located by matching all columns. Edit with care.", "data_viewer.message.connection_not_found": "Connection not found", "data_viewer.message.duckdb_query_timeout": "DuckDB query exceeded the connection timeout and was interrupted. Increase the connection timeout, or reduce the sort/filter scope and retry.", "data_viewer.message.fetch_data_failed_detail": "Error fetching data: {{detail}}", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index e5b9c244..cade019b 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "列 {{column}} の型 {{type}} は {{targetType}} に降格されました", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "列 {{column}} の型 {{type}} には専用の TDengine マッピングがないため、{{targetType}} に降格されました", "data_sync.warning.tdengine_target_user_defined_type_fallback": "列 {{column}} はユーザー定義型のため、{{targetType}} に降格されました", + "data_viewer.edit_hint.all_columns_locator": "主キーまたは一意インデックスが検出されなかったため、全列一致で行を特定します。編集には注意してください。", "data_viewer.message.connection_not_found": "接続が見つかりません", "data_viewer.message.duckdb_query_timeout": "DuckDB クエリが接続タイムアウトを超えたため中断されました。接続タイムアウトを延長するか、並べ替え/フィルター範囲を絞って再試行してください。", "data_viewer.message.fetch_data_failed_detail": "データ取得に失敗しました: {{detail}}", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index ecfdb87e..fd666f8c 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "Столбец {{column}} типа {{type}} деградирован до {{targetType}}", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "Для столбца {{column}} типа {{type}} нет отдельного сопоставления TDengine; он деградирован до {{targetType}}", "data_sync.warning.tdengine_target_user_defined_type_fallback": "Столбец {{column}} является пользовательским типом и деградирован до {{targetType}}", + "data_viewer.edit_hint.all_columns_locator": "Первичный ключ или уникальный индекс не обнаружены, поэтому строки будут определяться по совпадению всех столбцов. Редактируйте с осторожностью.", "data_viewer.message.connection_not_found": "Подключение не найдено", "data_viewer.message.duckdb_query_timeout": "Запрос DuckDB превысил тайм-аут подключения и был прерван. Увеличьте тайм-аут подключения или сократите область сортировки/фильтрации и повторите попытку.", "data_viewer.message.fetch_data_failed_detail": "Ошибка при получении данных: {{detail}}", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 661dd277..cd5b403c 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "字段 {{column}} 类型 {{type}} 已降级为 {{targetType}}", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "字段 {{column}} 类型 {{type}} 没有专用 TDengine 映射,已降级为 {{targetType}}", "data_sync.warning.tdengine_target_user_defined_type_fallback": "字段 {{column}} 为用户自定义类型,已降级为 {{targetType}}", + "data_viewer.edit_hint.all_columns_locator": "未检测到主键或唯一索引,将使用全列匹配定位行,请谨慎编辑。", "data_viewer.message.connection_not_found": "未找到连接", "data_viewer.message.duckdb_query_timeout": "DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。", "data_viewer.message.fetch_data_failed_detail": "获取数据失败:{{detail}}", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 1c3bb542..69af5cc2 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -4604,6 +4604,7 @@ "data_sync.warning.tdengine_target_type_fallback": "欄位 {{column}} 型別 {{type}} 已降級為 {{targetType}}", "data_sync.warning.tdengine_target_type_no_mapping_fallback": "欄位 {{column}} 型別 {{type}} 沒有專用 TDengine 對應,已降級為 {{targetType}}", "data_sync.warning.tdengine_target_user_defined_type_fallback": "欄位 {{column}} 為使用者自定義型別,已降級為 {{targetType}}", + "data_viewer.edit_hint.all_columns_locator": "未偵測到主鍵或唯一索引,將使用全欄位匹配定位資料列,請謹慎編輯。", "data_viewer.message.connection_not_found": "找不到連線", "data_viewer.message.duckdb_query_timeout": "DuckDB 查詢超過連線逾時時間,已中斷。請調高連線逾時時間,或縮小排序/篩選範圍後再試。", "data_viewer.message.fetch_data_failed_detail": "取得資料失敗:{{detail}}",