🐛 fix(duckdb): 修复无主键结果无法安全编辑

- 为 DuckDB 查询结果和表预览补充隐藏 rowid 定位列,允许无主键表安全提交修改
- DataGrid 提交变更时仅将 rowid 用作定位条件,避免把隐藏定位列写回业务字段
- DuckDB ApplyChanges 对 duckdb-rowid 改用未加引号的 rowid 条件,修复更新和删除失效
- 补充前后端回归测试,覆盖 QueryEditor、DataViewer、rowLocator 与 ApplyChanges 链路
This commit is contained in:
Syngnat
2026-06-05 14:05:18 +08:00
parent ea1737ab8d
commit 2254b76232
9 changed files with 378 additions and 15 deletions

View File

@@ -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: [],

View File

@@ -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';

View File

@@ -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)) {

View File

@@ -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',

View File

@@ -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,
];

View File

@@ -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']);
});
});

View File

@@ -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('未检测到主键或可用唯一索引,无法安全提交修改。');
};

View File

@@ -0,0 +1,160 @@
//go:build gonavi_full_drivers || gonavi_duckdb_driver
package db
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
)
const duckdbRecordingDriverName = "gonavi_duckdb_recording"
var (
registerDuckDBRecordingDriverOnce sync.Once
duckdbRecordingDriverMu sync.Mutex
duckdbRecordingDriverSeq int
duckdbRecordingDriverStates = map[string]*duckdbRecordingState{}
)
type duckdbRecordingState struct {
mu sync.Mutex
execQueries []string
execArgs [][]driver.NamedValue
}
func (s *duckdbRecordingState) snapshotExecQueries() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.execQueries...)
}
func (s *duckdbRecordingState) snapshotExecArgs() [][]driver.NamedValue {
s.mu.Lock()
defer s.mu.Unlock()
result := make([][]driver.NamedValue, len(s.execArgs))
for i, args := range s.execArgs {
result[i] = append([]driver.NamedValue(nil), args...)
}
return result
}
type duckdbRecordingDriver struct{}
func (duckdbRecordingDriver) Open(name string) (driver.Conn, error) {
duckdbRecordingDriverMu.Lock()
state := duckdbRecordingDriverStates[name]
duckdbRecordingDriverMu.Unlock()
if state == nil {
return nil, fmt.Errorf("recording state not found: %s", name)
}
return &duckdbRecordingConn{state: state}, nil
}
type duckdbRecordingConn struct {
state *duckdbRecordingState
}
func (c *duckdbRecordingConn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("prepare not supported in duckdb recording driver: %s", query)
}
func (c *duckdbRecordingConn) Close() error { return nil }
func (c *duckdbRecordingConn) Begin() (driver.Tx, error) { return duckdbRecordingTx{}, nil }
func (c *duckdbRecordingConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
c.state.mu.Lock()
defer c.state.mu.Unlock()
c.state.execQueries = append(c.state.execQueries, query)
c.state.execArgs = append(c.state.execArgs, append([]driver.NamedValue(nil), args...))
return driver.RowsAffected(1), nil
}
var _ driver.ExecerContext = (*duckdbRecordingConn)(nil)
type duckdbRecordingTx struct{}
func (duckdbRecordingTx) Commit() error { return nil }
func (duckdbRecordingTx) Rollback() error { return nil }
func openDuckDBRecordingDB(t *testing.T) (*sql.DB, *duckdbRecordingState) {
t.Helper()
registerDuckDBRecordingDriverOnce.Do(func() {
sql.Register(duckdbRecordingDriverName, duckdbRecordingDriver{})
})
duckdbRecordingDriverMu.Lock()
duckdbRecordingDriverSeq++
dsn := fmt.Sprintf("duckdb-recording-%d", duckdbRecordingDriverSeq)
state := &duckdbRecordingState{}
duckdbRecordingDriverStates[dsn] = state
duckdbRecordingDriverMu.Unlock()
dbConn, err := sql.Open(duckdbRecordingDriverName, dsn)
if err != nil {
t.Fatalf("打开 duckdb recording db 失败: %v", err)
}
t.Cleanup(func() {
_ = dbConn.Close()
duckdbRecordingDriverMu.Lock()
delete(duckdbRecordingDriverStates, dsn)
duckdbRecordingDriverMu.Unlock()
})
return dbConn, state
}
func TestDuckDBApplyChangesUsesUnquotedRowIDLocator(t *testing.T) {
t.Parallel()
dbConn, state := openDuckDBRecordingDB(t)
duckdb := &DuckDB{conn: dbConn}
changes := connection.ChangeSet{
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{
"rowid": 17,
},
Values: map[string]interface{}{
"name": "renamed",
},
}},
Deletes: []map[string]interface{}{
{"rowid": 21},
},
LocatorStrategy: "duckdb-rowid",
}
if err := duckdb.ApplyChanges("main.events", changes); err != nil {
t.Fatalf("ApplyChanges 返回错误: %v", err)
}
queries := state.snapshotExecQueries()
if len(queries) != 2 {
t.Fatalf("期望执行 2 条 SQL实际=%d %#v", len(queries), queries)
}
if queries[0] != `DELETE FROM "main"."events" WHERE rowid = ?` {
t.Fatalf("删除 SQL 不符合预期: %s", queries[0])
}
if queries[1] != `UPDATE "main"."events" SET "name" = ? WHERE rowid = ?` {
t.Fatalf("更新 SQL 不符合预期: %s", queries[1])
}
args := state.snapshotExecArgs()
if len(args) != 2 || len(args[0]) != 1 || len(args[1]) != 2 {
t.Fatalf("执行参数数量不符合预期: %#v", args)
}
if args[0][0].Value != 21 {
t.Fatalf("删除 rowid 参数错误: %#v", args[0])
}
if args[1][0].Value != "renamed" || args[1][1].Value != 17 {
t.Fatalf("更新参数错误: %#v", args[1])
}
}

View File

@@ -423,13 +423,24 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
}
for _, pk := range changes.Deletes {
isDuckDBRowIDLocator := strings.EqualFold(strings.TrimSpace(changes.LocatorStrategy), "duckdb-rowid")
buildWhere := func(keys map[string]interface{}) ([]string, []interface{}) {
var wheres []string
var args []interface{}
for k, v := range pk {
for k, v := range keys {
if isDuckDBRowIDLocator && strings.EqualFold(strings.TrimSpace(k), "rowid") {
wheres = append(wheres, "rowid = ?")
args = append(args, v)
continue
}
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
args = append(args, v)
}
return wheres, args
}
for _, pk := range changes.Deletes {
wheres, args := buildWhere(pk)
if len(wheres) == 0 {
continue
}
@@ -450,11 +461,8 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
continue
}
var wheres []string
for k, v := range update.Keys {
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
args = append(args, v)
}
wheres, whereArgs := buildWhere(update.Keys)
args = append(args, whereArgs...)
if len(wheres) == 0 {
return fmt.Errorf("更新操作需要主键条件")
}