mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 11:01:33 +08:00
🐛 fix(duckdb): 修复无主键结果无法安全编辑
- 为 DuckDB 查询结果和表预览补充隐藏 rowid 定位列,允许无主键表安全提交修改 - DataGrid 提交变更时仅将 rowid 用作定位条件,避免把隐藏定位列写回业务字段 - DuckDB ApplyChanges 对 duckdb-rowid 改用未加引号的 rowid 条件,修复更新和删除失效 - 补充前后端回归测试,覆盖 QueryEditor、DataViewer、rowLocator 与 ApplyChanges 链路
This commit is contained in:
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