mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +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('未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
};
|
||||
|
||||
|
||||
160
internal/db/duckdb_applychanges_test.go
Normal file
160
internal/db/duckdb_applychanges_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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("更新操作需要主键条件")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user