🐛 fix(query-results): 修复 SQLite 与 SQL Server 查询结果空页

- SQLite 只读查询优先普通 Query,并安全降级不支持的多结果集接口
- SQL Server 在结果集边界前恢复未扫描数据,保持 data/affectedRows 顺序
- 普通与事务查询过滤状态空壳,修正结果展示与审计返回行数
- 更新 SQL Server driver-agent revision 并补充 SQLite、MSSQL 回归测试
This commit is contained in:
Syngnat
2026-07-16 20:24:39 +08:00
parent 193d68398b
commit d3dee295d6
7 changed files with 473 additions and 11 deletions

View File

@@ -1496,7 +1496,7 @@ func (a *App) dbQueryMulti(
} else {
err = runStatementQuery()
}
if err == nil && usedMultiResult && nativeReadOnlyResultsMissingTabularPayload(isReadStmt, statementResults) {
if err == nil && usedMultiResult && shouldFallbackToPlainQueryAfterMultiResult(isReadStmt, statementResults, messages) {
logger.Warnf("DBQueryMulti 逐条多结果集返回空结果,将回退普通查询(第 %d/%d 条):%s SQL片段=%q", idx+1, len(statements), formatConnSummary(runConfig), sqlSnippet(stmt))
usedMultiResult = false
statementResults = nil
@@ -1641,6 +1641,9 @@ func nativeReadOnlyResultsMissingTabularPayload(allReadOnly bool, results []conn
return true
}
for _, result := range results {
if isAffectedRowsResultSet(result) {
continue
}
if len(result.Columns) > 0 || len(result.Rows) > 0 {
return false
}
@@ -1648,6 +1651,19 @@ func nativeReadOnlyResultsMissingTabularPayload(allReadOnly bool, results []conn
return true
}
func shouldFallbackToPlainQueryAfterMultiResult(readOnly bool, results []connection.ResultSetData, messages []string) bool {
if !readOnly {
return false
}
// Optional driver agents use nil results with no messages to signal that the
// native multi-result method is unsupported. Retrying is only safe for reads;
// query-first writes and stored procedures may already have side effects.
if results == nil && len(messages) == 0 {
return true
}
return nativeReadOnlyResultsMissingTabularPayload(true, results)
}
func shouldUseNativeMultiResultBatch(dbType string, statements []string, allReadOnly bool) bool {
if allReadOnly {
return !shouldPreferPlainReadQueryResult(dbType)
@@ -1670,7 +1686,8 @@ func shouldUseNativeMultiResultBatch(dbType string, statements []string, allRead
func shouldPreferPlainReadQueryResult(dbType string) bool {
switch strings.ToLower(strings.TrimSpace(dbType)) {
case "postgres", "postgresql",
case "sqlite",
"postgres", "postgresql",
"oracle",
"kingbase", "kingbase8", "kingbasees", "kingbasev8",
"highgo", "vastbase",

View File

@@ -2096,6 +2096,61 @@ func TestDBQueryMultiFallsBackWhenNativeReadOnlyBatchReturnsBlankResultSet(t *te
}
}
func TestDBQueryMultiFallsBackWhenSQLServerReadReturnsOnlyAffectedRowsStatus(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
})
query := "SELECT name FROM sys.databases"
statusOnlyResult := []connection.ResultSetData{{
Rows: []map[string]interface{}{{"affectedRows": int64(1)}},
Columns: []string{"affectedRows"},
}}
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"name": "master"}},
},
fieldMap: map[string][]string{
query: {"name"},
},
multiResult: map[string][]connection.ResultSetData{
query: statusOnlyResult,
},
queryErr: map[string]error{},
}
fakeDB := &fakeEmptyNativeMultiResultDB{
fakeBatchWriteDB: baseDB,
results: statusOnlyResult,
}
newDatabaseFunc = func(dbType string) (db.Database, error) {
return fakeDB, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
config := connection.ConnectionConfig{Type: "custom", Driver: "mssql", Host: "127.0.0.1", Port: 1433, User: "sa"}
result := app.DBQueryMulti(config, "master", query, "sqlserver-affected-only-read-fallback-test")
if !result.Success {
t.Fatalf("expected DBQueryMulti success, got failure: %s", result.Message)
}
if fakeDB.multiCalls != 1 {
t.Fatalf("expected one top-level native multi-result attempt, got %d", fakeDB.multiCalls)
}
if baseDB.session == nil || baseDB.session.queryCalls != 2 {
t.Fatalf("expected status-only result to retry session multi then plain query, session=%#v", baseDB.session)
}
resultSets, ok := result.Data.([]connection.ResultSetData)
if !ok || len(resultSets) != 1 {
t.Fatalf("expected one fallback result set, got %#v", result.Data)
}
if got := resultSets[0].Rows[0]["name"]; got != "master" {
t.Fatalf("expected fallback SQL Server row name=master, got %#v", got)
}
if got := queryResultRowsReturned(result); got != 1 {
t.Fatalf("expected SQL audit rows returned = 1, got %d", got)
}
}
func TestDBQueryMultiFallsBackToPlainQueryWhenSequentialMultiStillReturnsBlankResultSet(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() {
@@ -2854,6 +2909,49 @@ func TestExecuteManagedSQLTransactionStatementsPrefersPlainQueryForDamengReadRes
}
}
func TestExecuteManagedSQLTransactionStatementsFallsBackWhenSQLServerReadReturnsOnlyAffectedRowsStatus(t *testing.T) {
query := "SELECT name FROM sys.databases"
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"name": "master"}},
},
fieldMap: map[string][]string{
query: {"name"},
},
multiResult: map[string][]connection.ResultSetData{
query: {{
Rows: []map[string]interface{}{{"affectedRows": int64(1)}},
Columns: []string{"affectedRows"},
}},
},
queryErr: map[string]error{},
}
session := &fakeBatchWriteSession{parent: baseDB}
results, err := executeManagedSQLTransactionStatements(
context.Background(),
session,
connection.ConnectionConfig{Type: "sqlserver"},
[]string{query},
nil,
)
if err != nil {
t.Fatalf("expected executeManagedSQLTransactionStatements success, got %v", err)
}
if session.queryCalls != 2 {
t.Fatalf("expected SQL Server status-only result plus plain query fallback, got %d calls", session.queryCalls)
}
if len(results) != 1 || len(results[0].Rows) != 1 {
t.Fatalf("expected one fallback result row, got %#v", results)
}
if got := results[0].Rows[0]["name"]; got != "master" {
t.Fatalf("expected fallback SQL Server row name=master, got %#v", got)
}
if got := queryResultRowsReturned(connection.QueryResult{Success: true, Data: results}); got != 1 {
t.Fatalf("expected SQL audit rows returned = 1, got %d", got)
}
}
func TestExecuteManagedSQLTransactionStatementsPrefersPlainQueryForOceanBaseOracleReadResults(t *testing.T) {
query := "SELECT * FROM EINP_BASICINFO.AC01"
baseDB := &fakeBatchWriteDB{

View File

@@ -0,0 +1,252 @@
package app
import (
"context"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/sqlaudit"
)
// fakeUnsupportedMultiResultDB mirrors OptionalDriverAgentDB: the database-level
// multi-result method reports unsupported as nil while its pinned session only
// exposes the plain query API.
type fakeUnsupportedMultiResultDB struct {
*fakeBatchWriteDB
multiCalls int
session *fakePlainQuerySession
}
func (f *fakeUnsupportedMultiResultDB) QueryMulti(query string) ([]connection.ResultSetData, error) {
results, _, err := f.QueryMultiWithMessages(query)
return results, err
}
func (f *fakeUnsupportedMultiResultDB) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
return f.QueryMultiContextWithMessages(context.Background(), query)
}
func (f *fakeUnsupportedMultiResultDB) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
results, _, err := f.QueryMultiContextWithMessages(ctx, query)
return results, err
}
func (f *fakeUnsupportedMultiResultDB) QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error) {
f.multiCalls++
return nil, nil, nil
}
func (f *fakeUnsupportedMultiResultDB) OpenSessionExecer(context.Context) (db.StatementExecer, error) {
f.session = &fakePlainQuerySession{parent: f.fakeBatchWriteDB}
return f.session, nil
}
type fakePlainQuerySession struct {
parent *fakeBatchWriteDB
queryCalls int
}
func (s *fakePlainQuerySession) Query(query string) ([]map[string]interface{}, []string, error) {
return s.QueryContext(context.Background(), query)
}
func (s *fakePlainQuerySession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
s.queryCalls++
return s.parent.QueryContext(ctx, query)
}
func (s *fakePlainQuerySession) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
return s.QueryContextWithMessages(context.Background(), query)
}
func (s *fakePlainQuerySession) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
s.queryCalls++
return s.parent.QueryContextWithMessages(ctx, query)
}
func (s *fakePlainQuerySession) Exec(query string) (int64, error) {
return s.ExecContext(context.Background(), query)
}
func (s *fakePlainQuerySession) ExecContext(ctx context.Context, query string) (int64, error) {
return s.parent.ExecContext(ctx, query)
}
func (s *fakePlainQuerySession) Close() error { return nil }
type fakeUnsupportedMultiResultSession struct {
*fakePlainQuerySession
multiCalls int
}
func (s *fakeUnsupportedMultiResultSession) QueryMulti(query string) ([]connection.ResultSetData, error) {
results, _, err := s.QueryMultiWithMessages(query)
return results, err
}
func (s *fakeUnsupportedMultiResultSession) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
return s.QueryMultiContextWithMessages(context.Background(), query)
}
func (s *fakeUnsupportedMultiResultSession) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
results, _, err := s.QueryMultiContextWithMessages(ctx, query)
return results, err
}
func (s *fakeUnsupportedMultiResultSession) QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error) {
s.multiCalls++
return nil, nil, nil
}
func installFakeOptionalSQLiteDatabase(t *testing.T, fakeDB db.Database) {
t.Helper()
originalNewDatabaseFunc := newDatabaseFunc
originalDriverRuntimeSupportStatusFunc := driverRuntimeSupportStatusFunc
originalVerifyDriverAgentRevisionFunc := verifyDriverAgentRevisionFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
driverRuntimeSupportStatusFunc = originalDriverRuntimeSupportStatusFunc
verifyDriverAgentRevisionFunc = originalVerifyDriverAgentRevisionFunc
})
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
driverRuntimeSupportStatusFunc = func(string) (bool, string) { return true, "" }
verifyDriverAgentRevisionFunc = func(connection.ConnectionConfig) error { return nil }
}
func TestDBQueryMultiSQLiteAgentStyleQueryReturnsRowsAndAuditCount(t *testing.T) {
query := "SELECT id, name FROM orders"
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"id": 1, "name": "SQLite agent row"}},
},
fieldMap: map[string][]string{
query: {"id", "name"},
},
queryErr: map[string]error{},
}
fakeDB := &fakeUnsupportedMultiResultDB{fakeBatchWriteDB: baseDB}
installFakeOptionalSQLiteDatabase(t, fakeDB)
app := newSQLAuditTestApp(t)
config := connection.ConnectionConfig{Type: "sqlite", Host: "/tmp/orders.sqlite"}
result := app.DBQueryMulti(config, "", query, "sqlite-agent-select")
if !result.Success {
t.Fatalf("SQLite agent-style SELECT returned failure: %s", result.Message)
}
resultSets, ok := result.Data.([]connection.ResultSetData)
if !ok || len(resultSets) != 1 || len(resultSets[0].Rows) != 1 {
t.Fatalf("SQLite agent-style SELECT result sets = %#v, want one row", result.Data)
}
if fakeDB.multiCalls != 0 {
t.Fatalf("SQLite plain SELECT should not probe unsupported multi-result API, calls=%d", fakeDB.multiCalls)
}
if fakeDB.session == nil || fakeDB.session.queryCalls != 1 {
t.Fatalf("SQLite plain SELECT should use one pinned-session query, session=%#v", fakeDB.session)
}
events := loadSQLAuditEvents(t, app, sqlaudit.Filter{Search: "sqlite-agent-select", PageSize: 10})
if len(events) != 1 || events[0].RowsReturned != 1 {
t.Fatalf("SQLite SELECT audit events = %#v, want RowsReturned=1", events)
}
}
func TestDBQueryMultiFallsBackWhenMultiResultReportsUnsupportedAsNil(t *testing.T) {
query := "SELECT id FROM users"
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"id": 7}},
},
fieldMap: map[string][]string{
query: {"id"},
},
queryErr: map[string]error{},
}
fakeDB := &fakeUnsupportedMultiResultDB{fakeBatchWriteDB: baseDB}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
app := NewApp()
result := app.DBQueryMulti(connection.ConnectionConfig{Type: "mysql"}, "main", query, "nil-multi-result")
if !result.Success {
t.Fatalf("DBQueryMulti returned failure: %s", result.Message)
}
resultSets, ok := result.Data.([]connection.ResultSetData)
if !ok || len(resultSets) != 1 || len(resultSets[0].Rows) != 1 {
t.Fatalf("DBQueryMulti result sets = %#v, want one row", result.Data)
}
if fakeDB.multiCalls != 2 {
t.Fatalf("expected batch and statement multi-result probes before fallback, calls=%d", fakeDB.multiCalls)
}
if fakeDB.session == nil || fakeDB.session.queryCalls != 1 {
t.Fatalf("expected one plain pinned-session fallback, session=%#v", fakeDB.session)
}
}
func TestDBQueryMultiDoesNotRetryQueryFirstWriteWhenMultiResultReturnsNil(t *testing.T) {
query := "INSERT INTO users(id) VALUES (11) RETURNING id"
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"id": 11}},
},
fieldMap: map[string][]string{
query: {"id"},
},
queryErr: map[string]error{},
}
fakeDB := &fakeUnsupportedMultiResultDB{fakeBatchWriteDB: baseDB}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) { return fakeDB, nil }
app := NewApp()
result := app.DBQueryMulti(connection.ConnectionConfig{Type: "postgres"}, "main", query, "nil-write-result")
if !result.Success {
t.Fatalf("query-first write returned failure: %s", result.Message)
}
if fakeDB.multiCalls != 1 {
t.Fatalf("expected the query-first write to execute once, multi calls=%d", fakeDB.multiCalls)
}
if fakeDB.session == nil || fakeDB.session.queryCalls != 0 {
t.Fatalf("query-first write must not retry through plain Query, session=%#v", fakeDB.session)
}
}
func TestManagedTransactionFallsBackWhenMultiResultReportsUnsupportedAsNil(t *testing.T) {
query := "SELECT id FROM users"
baseDB := &fakeBatchWriteDB{
queryMap: map[string][]map[string]interface{}{
query: {{"id": 9}},
},
fieldMap: map[string][]string{
query: {"id"},
},
queryErr: map[string]error{},
}
session := &fakeUnsupportedMultiResultSession{
fakePlainQuerySession: &fakePlainQuerySession{parent: baseDB},
}
resultSets, err := executeManagedSQLTransactionStatements(
context.Background(),
session,
connection.ConnectionConfig{Type: "mysql"},
[]string{query},
defaultDBBackendText,
)
if err != nil {
t.Fatalf("managed transaction SELECT returned failure: %v", err)
}
if len(resultSets) != 1 || len(resultSets[0].Rows) != 1 {
t.Fatalf("managed transaction result sets = %#v, want one row", resultSets)
}
if session.multiCalls != 1 || session.queryCalls != 1 {
t.Fatalf("expected one multi-result probe and one plain fallback, multi=%d plain=%d", session.multiCalls, session.queryCalls)
}
}
var _ db.MultiResultQueryMessageExecer = (*fakeUnsupportedMultiResultDB)(nil)
var _ db.SessionExecerProvider = (*fakeUnsupportedMultiResultDB)(nil)
var _ db.StatementQueryMessageExecer = (*fakePlainQuerySession)(nil)
var _ db.StatementMultiResultQueryMessageExecer = (*fakeUnsupportedMultiResultSession)(nil)

View File

@@ -511,6 +511,21 @@ func executeManagedSQLTransactionStatementsWithObserver(
} else {
err = buildTransactionQueryUnsupportedError()
}
if err == nil && usedMultiResult && shouldFallbackToPlainQueryAfterMultiResult(isReadStmt, statementResults, messages) {
logger.Warnf("托管事务多结果集返回空结果,将回退普通查询(第 %d/%d 条):类型=%s SQL片段=%q", statementIndex, statementCount, resolvedDBType, sqlSnippet(stmt))
usedMultiResult = false
statementResults = nil
data = nil
columns = nil
messages = nil
if sessionQueryMessageTarget != nil {
data, columns, messages, err = sessionQueryMessageTarget.QueryContextWithMessages(ctx, stmt)
} else if sessionQueryTarget != nil {
data, columns, err = sessionQueryTarget.QueryContext(ctx, stmt)
} else {
err = buildTransactionQueryUnsupportedError()
}
}
if err == nil {
if usedMultiResult {
var rowsAffected, rowsReturned int64

View File

@@ -9,7 +9,7 @@ func init() {
"diros": "src-7d4fe439271d0c56",
"starrocks": "src-ce9ee22641a32f46",
"sphinx": "src-08f5ae54efb3d9df",
"sqlserver": "src-33b3b2c6dad5b3e6",
"sqlserver": "src-bd1323910fc119ac",
"sqlite": "src-96dfa25b3042b2d5",
"duckdb": "src-8804eb2cdbc89433",
"dameng": "src-016e77082aea6718",

View File

@@ -40,10 +40,33 @@ func scanSQLServerRowsWithMessages(ctx context.Context, rows *sql.Rows, retmsg *
}
var (
resultSets []connection.ResultSetData
messages []string
allMessages []string
resultSets []connection.ResultSetData
messages []string
allMessages []string
currentResultScanned bool
currentResultStart int
)
// go-mssqldb can emit a result-set boundary without MsgNext. Recover the
// unread rows before advancing and keep them before any affectedRows status.
scanCurrentResultIfNeeded := func() error {
if currentResultScanned {
return nil
}
result, err := scanSQLServerFallbackResultSet(rows)
if err != nil {
return err
}
currentResultScanned = true
if len(result.Columns) == 0 && len(result.Rows) == 0 {
return nil
}
result.Messages = append([]string(nil), messages...)
resultSets = append(resultSets, connection.ResultSetData{})
copy(resultSets[currentResultStart+1:], resultSets[currentResultStart:])
resultSets[currentResultStart] = result
messages = nil
return nil
}
active := true
for active {
raw := retmsg.Message(ctx)
@@ -70,6 +93,7 @@ func scanSQLServerRowsWithMessages(ctx context.Context, rows *sql.Rows, retmsg *
Columns: cols,
Messages: append([]string(nil), messages...),
})
currentResultScanned = true
messages = nil
case sqlexp.MsgRowsAffected:
resultSets = append(resultSets, connection.ResultSetData{
@@ -79,13 +103,23 @@ func scanSQLServerRowsWithMessages(ctx context.Context, rows *sql.Rows, retmsg *
})
messages = nil
case sqlexp.MsgNextResultSet:
if err := scanCurrentResultIfNeeded(); err != nil {
return resultSets, messages, err
}
active = rows.NextResultSet()
if active {
currentResultScanned = false
currentResultStart = len(resultSets)
}
case sqlexp.MsgError:
return resultSets, messages, msg.Error
default:
active = false
}
}
if err := scanCurrentResultIfNeeded(); err != nil {
return resultSets, messages, err
}
if len(messages) > 0 {
resultSets = append(resultSets, connection.ResultSetData{
@@ -95,11 +129,7 @@ func scanSQLServerRowsWithMessages(ctx context.Context, rows *sql.Rows, retmsg *
})
}
if len(resultSets) == 0 {
fallbackResult, err := scanSQLServerFallbackResultSet(rows)
if err != nil {
return resultSets, allMessages, err
}
resultSets = []connection.ResultSetData{fallbackResult}
resultSets = []connection.ResultSetData{emptySQLServerRowsResultSet()}
}
if err := rows.Err(); err != nil {
return resultSets, allMessages, err

View File

@@ -13,6 +13,7 @@ import (
"GoNavi-Wails/shared/i18n"
"github.com/golang-sql/sqlexp"
_ "modernc.org/sqlite"
)
@@ -162,6 +163,55 @@ func TestScanSQLServerFallbackResultSetPreservesColumnsWhenResultHasNoRows(t *te
}
}
func TestScanSQLServerRowsWithMessagesRecoversRowsWhenMessageLoopOmitsMsgNext(t *testing.T) {
dbConn, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
_ = dbConn.Close()
})
rows, err := dbConn.Query("SELECT 'config:roomType:add' AS menuName")
if err != nil {
t.Fatalf("query rows: %v", err)
}
defer rows.Close()
ctx := context.Background()
retmsg := &sqlexp.ReturnMessage{}
sqlexp.ReturnMessageInit(retmsg)
for _, message := range []sqlexp.RawMessage{
sqlexp.MsgRowsAffected{Count: 1},
sqlexp.MsgNextResultSet{},
} {
if err := sqlexp.ReturnMessageEnqueue(ctx, retmsg, message); err != nil {
t.Fatalf("enqueue SQL Server message: %v", err)
}
}
resultSets, messages, err := scanSQLServerRowsWithMessages(ctx, rows, retmsg)
if err != nil {
t.Fatalf("scanSQLServerRowsWithMessages returned error: %v", err)
}
if len(messages) != 0 {
t.Fatalf("expected no SQL Server notices, got %#v", messages)
}
if len(resultSets) != 2 {
t.Fatalf("expected recovered SELECT rows plus affected-row status, got %#v", resultSets)
}
if !reflect.DeepEqual(resultSets[0].Columns, []string{"menuName"}) ||
len(resultSets[0].Rows) != 1 ||
resultSets[0].Rows[0]["menuName"] != "config:roomType:add" {
t.Fatalf("expected recovered SELECT rows first, got %#v", resultSets)
}
if !reflect.DeepEqual(resultSets[1].Columns, []string{"affectedRows"}) ||
len(resultSets[1].Rows) != 1 ||
resultSets[1].Rows[0]["affectedRows"] != int64(1) {
t.Fatalf("expected affected-row status after SELECT rows, got %#v", resultSets)
}
}
func TestSQLServerMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {