mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 18:41:42 +08:00
🐛 fix(duckdb): 修复无主键结果无法安全编辑
- 为 DuckDB 查询结果和表预览补充隐藏 rowid 定位列,允许无主键表安全提交修改 - DataGrid 提交变更时仅将 rowid 用作定位条件,避免把隐藏定位列写回业务字段 - DuckDB ApplyChanges 对 duckdb-rowid 改用未加引号的 rowid 条件,修复更新和删除失效 - 补充前后端回归测试,覆盖 QueryEditor、DataViewer、rowLocator 与 ApplyChanges 链路
This commit is contained in:
@@ -8,7 +8,7 @@ import DataGrid, {
|
||||
GONAVI_ROW_KEY,
|
||||
hasDataGridVirtualEditRenderVersionChanged,
|
||||
} from './DataGrid';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
@@ -415,6 +415,37 @@ describe('DataGrid commit change set', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses hidden DuckDB rowid only as locator and excludes it from update values', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
modifiedRows: {
|
||||
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [DUCKDB_ROWID_LOCATOR_COLUMN]: 18 },
|
||||
},
|
||||
deletedRowKeys: new Set(),
|
||||
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 }],
|
||||
editLocator: {
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
},
|
||||
visibleColumnNames: ['NAME'],
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
changes: {
|
||||
inserts: [],
|
||||
updates: [{ keys: { rowid: 17 }, values: { NAME: 'new-name' } }],
|
||||
deletes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('commits only writable result columns and maps aliases back to table columns', () => {
|
||||
const result = buildDataGridCommitChangeSet({
|
||||
addedRows: [],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TabData } from '../types';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
|
||||
import DataViewer from './DataViewer';
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
@@ -207,6 +207,39 @@ describe('DataViewer safe editing locator', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('uses hidden DuckDB rowid when no primary or unique key is available', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
data: [{ name: 'name', key: '' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValue({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
success: true,
|
||||
fields: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
data: [{ name: 'launch', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 }],
|
||||
});
|
||||
|
||||
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-rowid', dbName: 'main', tableName: 'main.events', title: 'events' }));
|
||||
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`rowid AS "${DUCKDB_ROWID_LOCATOR_COLUMN}"`))).toBe(true);
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('enables MongoDB table preview editing through the _id locator', async () => {
|
||||
storeState.connections[0].config.type = 'mongodb';
|
||||
storeState.connections[0].config.database = 'app';
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
validateQuickWhereCondition,
|
||||
} from '../utils/dataGridWhereFilter';
|
||||
import {
|
||||
DUCKDB_ROWID_LOCATOR_COLUMN,
|
||||
ORACLE_ROWID_LOCATOR_COLUMN,
|
||||
resolveEditRowLocator,
|
||||
type EditRowLocator,
|
||||
@@ -163,13 +164,18 @@ const buildDataViewerBaseSelectSQL = (
|
||||
locator?: EditRowLocator,
|
||||
): string => {
|
||||
const quotedTableName = quoteQualifiedIdent(dbType, tableName);
|
||||
if (locator?.strategy !== 'oracle-rowid') {
|
||||
if (locator?.strategy !== 'oracle-rowid' && locator?.strategy !== 'duckdb-rowid') {
|
||||
return `SELECT * FROM ${quotedTableName} ${whereSQL}`;
|
||||
}
|
||||
|
||||
const alias = 'gonavi_row_source';
|
||||
const rowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN);
|
||||
return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
|
||||
if (locator?.strategy === 'duckdb-rowid') {
|
||||
const duckdbRowIDAlias = quoteIdentPart(dbType, DUCKDB_ROWID_LOCATOR_COLUMN);
|
||||
return `SELECT ${alias}.*, ${alias}.rowid AS ${duckdbRowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
|
||||
}
|
||||
|
||||
const oracleRowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN);
|
||||
return `SELECT ${alias}.*, ${alias}.ROWID AS ${oracleRowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
|
||||
};
|
||||
|
||||
const resolveDuckDBSchemaAndTable = (dbName: string, tableName: string) => {
|
||||
@@ -575,13 +581,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const resultColumns = getTableColumnNames(columnDefs);
|
||||
const locatorColumns = isOracleLikeDialect(dbType)
|
||||
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
|
||||
: resultColumns;
|
||||
: (String(dbType || '').trim().toLowerCase() === 'duckdb'
|
||||
? [...resultColumns, DUCKDB_ROWID_LOCATOR_COLUMN]
|
||||
: resultColumns);
|
||||
let nextLocator = resolveEditRowLocator({
|
||||
dbType,
|
||||
resultColumns: locatorColumns,
|
||||
primaryKeys,
|
||||
indexes,
|
||||
allowOracleRowID: true,
|
||||
allowDuckDBRowID: String(dbType || '').trim().toLowerCase() === 'duckdb',
|
||||
});
|
||||
|
||||
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
|
||||
|
||||
@@ -3387,6 +3387,52 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses hidden DuckDB rowid when query results have no primary or unique key', async () => {
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ columns: ['NAME', '__gonavi_duckdb_rowid__'], rows: [{ NAME: 'launch', __gonavi_duckdb_rowid__: 17 }] }],
|
||||
});
|
||||
backendApp.DBGetColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ name: 'name', key: '' }],
|
||||
});
|
||||
backendApp.DBGetIndexes.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await findButton(renderer!, '运行').props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(dataGridState.latestProps?.tableName).toBe('main.events');
|
||||
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: ['__gonavi_duckdb_rowid__'],
|
||||
hiddenColumns: ['__gonavi_duckdb_rowid__'],
|
||||
writableColumns: {
|
||||
NAME: 'name',
|
||||
},
|
||||
readOnly: false,
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(false);
|
||||
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('rowid AS "__gonavi_duckdb_rowid__"');
|
||||
expect(messageApi.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
|
||||
@@ -24,7 +24,11 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
|
||||
import { normalizeSidebarViewName } from '../utils/sidebarMetadata';
|
||||
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
|
||||
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
|
||||
import {
|
||||
DUCKDB_ROWID_LOCATOR_COLUMN,
|
||||
ORACLE_ROWID_LOCATOR_COLUMN,
|
||||
type EditRowLocator,
|
||||
} from '../utils/rowLocator';
|
||||
import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
|
||||
import {
|
||||
getColumnDefinitionKey,
|
||||
@@ -579,6 +583,10 @@ const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string
|
||||
`${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
|
||||
);
|
||||
|
||||
const buildDuckDBRowIDExpression = (dbType: string, sourceAlias?: string): string => (
|
||||
`${sourceAlias ? `${sourceAlias}.` : ''}rowid AS ${quoteIdentPart(dbType, DUCKDB_ROWID_LOCATOR_COLUMN)}`
|
||||
);
|
||||
|
||||
const escapeMetadataSqlLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
|
||||
const quoteSqlServerDbIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||
@@ -1834,6 +1842,7 @@ const resolveQueryLocatorPlan = async ({
|
||||
const appendExpressions: string[] = [];
|
||||
const hiddenColumns: string[] = [];
|
||||
let needsOracleRowIDExpression = false;
|
||||
let needsDuckDBRowIDExpression = false;
|
||||
|
||||
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
|
||||
const valueColumns = locatorColumns.map((column, index) => {
|
||||
@@ -1872,6 +1881,16 @@ const resolveQueryLocatorPlan = async ({
|
||||
writableColumns,
|
||||
readOnly: false,
|
||||
};
|
||||
} else if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
|
||||
needsDuckDBRowIDExpression = true;
|
||||
plan.editLocator = {
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
writableColumns,
|
||||
readOnly: false,
|
||||
};
|
||||
} else {
|
||||
const reason = !resIndexes?.success
|
||||
? '无法加载唯一索引元数据,无法安全提交修改。'
|
||||
@@ -1883,6 +1902,7 @@ const resolveQueryLocatorPlan = async ({
|
||||
|
||||
const executableAppendExpressions = [
|
||||
...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []),
|
||||
...(needsDuckDBRowIDExpression ? [buildDuckDBRowIDExpression(dbType)] : []),
|
||||
...appendExpressions,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DUCKDB_ROWID_LOCATOR_COLUMN,
|
||||
ORACLE_ROWID_LOCATOR_COLUMN,
|
||||
filterHiddenLocatorColumns,
|
||||
resolveEditRowLocator,
|
||||
@@ -103,6 +104,20 @@ describe('resolveEditRowLocator', () => {
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses DuckDB rowid when no primary or unique key is available', () => {
|
||||
expect(resolveEditRowLocator({
|
||||
dbType: 'duckdb',
|
||||
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
allowDuckDBRowID: true,
|
||||
})).toEqual({
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
readOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowLocatorValues', () => {
|
||||
@@ -131,6 +146,19 @@ describe('resolveRowLocatorValues', () => {
|
||||
error: '定位列 EMAIL 的值为空,无法安全提交修改。',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts DuckDB rowid locator values from the original row', () => {
|
||||
const locator = resolveEditRowLocator({
|
||||
dbType: 'duckdb',
|
||||
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
allowDuckDBRowID: true,
|
||||
});
|
||||
|
||||
expect(resolveRowLocatorValues(locator, { name: 'launch', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 })).toEqual({
|
||||
ok: true,
|
||||
values: { rowid: 17 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterHiddenLocatorColumns', () => {
|
||||
@@ -143,4 +171,14 @@ describe('filterHiddenLocatorColumns', () => {
|
||||
|
||||
expect(filterHiddenLocatorColumns(['NAME', ORACLE_ROWID_LOCATOR_COLUMN], locator)).toEqual(['NAME']);
|
||||
});
|
||||
|
||||
it('removes hidden DuckDB rowid columns from displayed columns', () => {
|
||||
const locator = resolveEditRowLocator({
|
||||
dbType: 'duckdb',
|
||||
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
|
||||
allowDuckDBRowID: true,
|
||||
});
|
||||
|
||||
expect(filterHiddenLocatorColumns(['name', DUCKDB_ROWID_LOCATOR_COLUMN], locator)).toEqual(['name']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyIns
|
||||
import { isOracleLikeDialect } from './sqlDialect';
|
||||
|
||||
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' | 'none';
|
||||
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'duckdb-rowid' | 'none';
|
||||
|
||||
export type EditRowLocator = {
|
||||
strategy: RowLocatorStrategy;
|
||||
@@ -22,6 +23,7 @@ export type ResolveEditRowLocatorParams = {
|
||||
primaryKeys?: string[];
|
||||
indexes?: IndexDefinition[];
|
||||
allowOracleRowID?: boolean;
|
||||
allowDuckDBRowID?: boolean;
|
||||
};
|
||||
|
||||
export type ResolveRowLocatorValuesResult =
|
||||
@@ -54,6 +56,7 @@ export const resolveEditRowLocator = ({
|
||||
primaryKeys = [],
|
||||
indexes,
|
||||
allowOracleRowID = false,
|
||||
allowDuckDBRowID = false,
|
||||
}: ResolveEditRowLocatorParams): EditRowLocator => {
|
||||
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
|
||||
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
|
||||
@@ -93,10 +96,25 @@ export const resolveEditRowLocator = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb' && hasColumn(columns, DUCKDB_ROWID_LOCATOR_COLUMN)) {
|
||||
const rowIDColumn = findColumn(columns, DUCKDB_ROWID_LOCATOR_COLUMN);
|
||||
return {
|
||||
strategy: 'duckdb-rowid',
|
||||
columns: ['rowid'],
|
||||
valueColumns: [rowIDColumn],
|
||||
hiddenColumns: [rowIDColumn],
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
|
||||
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID,无法安全提交修改。');
|
||||
}
|
||||
|
||||
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb') {
|
||||
return buildReadOnlyLocator('未检测到主键、可用唯一索引或 DuckDB rowid,无法安全提交修改。');
|
||||
}
|
||||
|
||||
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user