Files
MyGoNavi/internal/app/methods_file_sql_execution_test.go
Syngnat 390a249105 feat(data-import): 重构大文件导入与安全任务运行时
- SQL、CSV、JSON 与 XLSX 改为资源受限的流式解析,并支持压缩、编码和方言预检

- 增加遇错停止/继续策略、持久任务、错误行导出、源文件身份与安全取消

- 为数据库驱动补齐上下文事务和写入结果未知语义,避免失败重放与脏会话复用
2026-08-08 20:51:23 +08:00

2092 lines
75 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
func TestResolveSQLFileExecutionProgressPercentReservesCompletionForTerminalState(t *testing.T) {
tests := []struct {
name string
status string
bytesRead int64
totalSize int64
want float64
}{
{name: "running reader reached eof", status: "running", bytesRead: 128, totalSize: 128, want: 99},
{name: "running partial read", status: "running", bytesRead: 64, totalSize: 128, want: 50},
{name: "done", status: "done", bytesRead: 128, totalSize: 128, want: 100},
{name: "unknown size", status: "running", bytesRead: 64, totalSize: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveSQLFileExecutionProgressPercent(tt.status, tt.bytesRead, tt.totalSize); got != tt.want {
t.Fatalf("percent = %v, want %v", got, tt.want)
}
})
}
}
type fakeSQLFileBatchDB struct {
batchCalls int
execCalls int
batchQueries []string
execQueries []string
failBatch bool
failBatchSQL string
batchError error
failExecSQL string
execError func(string) error
onBatch func()
session *fakeSQLFileSessionDB
}
func (f *fakeSQLFileBatchDB) Connect(config connection.ConnectionConfig) error {
return nil
}
func (f *fakeSQLFileBatchDB) Close() error {
return nil
}
func (f *fakeSQLFileBatchDB) Ping() error {
return nil
}
func (f *fakeSQLFileBatchDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (f *fakeSQLFileBatchDB) Exec(query string) (int64, error) {
f.execCalls++
f.execQueries = append(f.execQueries, query)
if f.execError != nil {
if err := f.execError(query); err != nil {
return 0, err
}
}
if f.failExecSQL != "" && strings.Contains(query, f.failExecSQL) {
return 0, errors.New("exec failed")
}
return 1, nil
}
func (f *fakeSQLFileBatchDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
f.batchCalls++
f.batchQueries = append(f.batchQueries, query)
if f.onBatch != nil {
f.onBatch()
}
if f.failBatch || (f.failBatchSQL != "" && strings.Contains(query, f.failBatchSQL)) {
if f.batchError != nil {
return 0, f.batchError
}
return 0, errors.New("batch failed")
}
return int64(strings.Count(query, "INSERT")), nil
}
func TestExecuteSQLFileStreamRedactsBatchExecutionErrors(t *testing.T) {
const secret = "password=super-secret-token"
fakeDB := &fakeSQLFileBatchDB{
failBatch: true,
batchError: errors.New("duplicate key value is (alice@example.com); " + secret),
}
input := "INSERT INTO demo(email) VALUES ('alice@example.com');"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "postgres",
ContinueOnError: false,
Text: func(key string, params map[string]any) string {
return fmt.Sprintf("%s: %v", key, params["detail"])
},
}, nil)
if err == nil {
t.Fatal("failed batch must stop SQL file execution")
}
combined := err.Error() + " " + strings.Join(result.Errors, " ")
for _, sensitive := range []string{secret, "super-secret-token", "alice@example.com"} {
if strings.Contains(combined, sensitive) {
t.Fatalf("batch error leaked %q: %s", sensitive, combined)
}
}
}
func (f *fakeSQLFileBatchDB) GetDatabases() ([]string, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetTables(dbName string) ([]string, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeSQLFileBatchDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeSQLFileBatchDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
var _ db.BatchWriteExecer = (*fakeSQLFileBatchDB)(nil)
func (f *fakeSQLFileBatchDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
f.session = &fakeSQLFileSessionDB{parent: f}
return f.session, nil
}
type fakeSQLFileSessionDB struct {
parent *fakeSQLFileBatchDB
closed bool
discarded bool
}
type fakeSQLFileBatchCapabilityDB struct {
*fakeSQLFileBatchDB
batchWritesEnabled bool
}
type fakeSQLFileUnpinnedDB struct {
db.Database
execCalls int
}
func (*fakeSQLFileUnpinnedDB) Connect(connection.ConnectionConfig) error { return nil }
func (*fakeSQLFileUnpinnedDB) Close() error { return nil }
func (*fakeSQLFileUnpinnedDB) Ping() error { return nil }
func (database *fakeSQLFileUnpinnedDB) Exec(string) (int64, error) {
database.execCalls++
return 1, nil
}
func (f *fakeSQLFileBatchCapabilityDB) SupportsBatchWrites() bool {
return f != nil && f.batchWritesEnabled
}
func (s *fakeSQLFileSessionDB) Exec(query string) (int64, error) {
return s.ExecContext(context.Background(), query)
}
func (s *fakeSQLFileSessionDB) ExecContext(ctx context.Context, query string) (int64, error) {
return s.parent.Exec(query)
}
func (s *fakeSQLFileSessionDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
return s.parent.ExecBatchContext(ctx, query)
}
func (s *fakeSQLFileSessionDB) Close() error {
s.closed = true
return nil
}
func (s *fakeSQLFileSessionDB) Discard() error {
s.discarded = true
return nil
}
func TestExecuteSQLFileStreamBatchesWriteStatements(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("expected 3 executed and 0 failed, got %#v", result)
}
if fakeDB.batchCalls != 1 {
t.Fatalf("expected one batch call, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls != 2 {
t.Fatalf("expected transaction wrapper exec calls only, got %d", fakeDB.execCalls)
}
if fakeDB.execQueries[0] != "START TRANSACTION" || fakeDB.execQueries[1] != "COMMIT" {
t.Fatalf("expected transaction wrapper around batch, got %#v", fakeDB.execQueries)
}
if fakeDB.session == nil || !fakeDB.session.closed {
t.Fatalf("expected SQL file import to use and close an isolated session")
}
if !strings.Contains(fakeDB.batchQueries[0], "INSERT INTO demo(id) VALUES (1);\nINSERT INTO demo(id) VALUES (2)") {
t.Fatalf("expected batched SQL to join statements, got %q", fakeDB.batchQueries[0])
}
}
func TestExecuteSQLFileStreamMarksAutomaticBatchTransactionFinishFailureUnknown(t *testing.T) {
tests := []struct {
name string
failBatch bool
finishSQL string
}{
{name: "commit fails", finishSQL: "COMMIT"},
{name: "rollback fails", failBatch: true, finishSQL: "ROLLBACK"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{
failBatch: test.failBatch,
failExecSQL: test.finishSQL,
}
input := "INSERT INTO demo(id) VALUES (1);\nINSERT INTO demo(id) VALUES (2);"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
ContinueOnError: false,
}, nil)
if err == nil {
t.Fatal("failed transaction finish must stop SQL file execution")
}
if !result.OutcomeUnknown {
t.Fatalf("failed %s after dispatch must retain an unknown commit outcome: %#v", test.finishSQL, result)
}
})
}
}
func TestExecuteSQLFileStreamSkipsBatchAttemptWhenRuntimeCapabilityIsDisabled(t *testing.T) {
baseDB := &fakeSQLFileBatchDB{}
fakeDB := &fakeSQLFileBatchCapabilityDB{
fakeSQLFileBatchDB: baseDB,
batchWritesEnabled: false,
}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 2 || result.Failed != 0 {
t.Fatalf("expected both statements to execute sequentially, got %#v", result)
}
if baseDB.batchCalls != 0 {
t.Fatalf("disabled runtime capability still attempted %d batches", baseDB.batchCalls)
}
if baseDB.execCalls != 2 {
t.Fatalf("expected two direct statement calls without failed batch preflight, got %d: %#v", baseDB.execCalls, baseDB.execQueries)
}
}
func TestExecuteSQLFileStreamFlushesBatchBeforeReadStatement(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"SELECT * FROM demo;",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 4 || result.Failed != 0 {
t.Fatalf("expected 4 executed and 0 failed, got %#v", result)
}
if fakeDB.batchCalls != 2 {
t.Fatalf("expected two batch calls around read statement, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls != 5 {
t.Fatalf("expected transaction wrappers plus one read exec call, got %d", fakeDB.execCalls)
}
if fakeDB.execQueries[2] != "SELECT * FROM demo" {
t.Fatalf("expected read statement to execute outside batch, got %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamUsesSafeSequentialExecutionForMySQLFamilyContinueOnError(t *testing.T) {
for _, dbType := range []string{"mysql", "mariadb"} {
t.Run(dbType, func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failBatch: true, failExecSQL: "VALUES (2)"}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: dbType,
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 2 || result.Failed != 1 {
t.Fatalf("expected 2 executed and 1 failed, got %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("%s continue mode must not batch before knowing whether writes are transactional, got %d calls", dbType, fakeDB.batchCalls)
}
if fakeDB.execCalls != 3 {
t.Fatalf("expected exactly 3 sequential statement calls, got %d", fakeDB.execCalls)
}
if fakeDB.execQueries[0] != "INSERT INTO demo(id) VALUES (1)" || fakeDB.execQueries[2] != "INSERT INTO demo(id) VALUES (3)" {
t.Fatalf("unexpected sequential execution order: %#v", fakeDB.execQueries)
}
if len(result.Errors) != 1 || result.Errors[0] != "file.backend.message.statement_failed" {
t.Fatalf("expected per-statement error for second statement, got %#v", result.Errors)
}
})
}
}
func TestExecuteSQLFileStreamAdaptivelyNarrowsLargeFailedBatchInContinueMode(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{
failBatchSQL: "VALUES (33)",
failExecSQL: "VALUES (33)",
}
statements := make([]string, 64)
for index := range statements {
statements[index] = fmt.Sprintf("INSERT INTO demo(id) VALUES (%d);", index+1)
}
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(strings.Join(statements, "\n")), sqlFileExecutionOptions{
DBType: "postgres",
BatchMaxStatements: 100,
BatchMaxBytes: 64 * 1024,
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 63 || result.Failed != 1 {
t.Fatalf("expected 63 executed and 1 failed, got %#v", result)
}
if fakeDB.batchCalls != 5 {
t.Fatalf("expected five adaptive batch attempts, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls >= 40 {
t.Fatalf("adaptive isolation regressed toward whole-batch sequential replay: execCalls=%d", fakeDB.execCalls)
}
}
func TestExecuteSQLFileStreamStopsAfterFailedBatchWithoutSequentialReplay(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failBatch: true, failExecSQL: "VALUES (2)"}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
ContinueOnError: false,
}, nil)
if err == nil {
t.Fatal("expected failed batch to stop SQL file execution")
}
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected stop-on-error sentinel, got %v", err)
}
if result.Executed != 0 || result.Failed != 1 {
t.Fatalf("expected 0 executed and 1 observed failure, got %#v", result)
}
if !result.OutcomeUnknown {
t.Fatalf("MySQL-family batch rollback cannot prove non-transactional tables were restored: %#v", result)
}
if fakeDB.batchCalls != 1 {
t.Fatalf("expected one failed batch attempt, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls != 2 {
t.Fatalf("expected only transaction begin and rollback, got %d calls: %#v", fakeDB.execCalls, fakeDB.execQueries)
}
if fakeDB.execQueries[0] != "START TRANSACTION" || fakeDB.execQueries[1] != "ROLLBACK" {
t.Fatalf("expected failed batch to roll back without replay, got %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamDoesNotReplayWhenAutomaticBatchBeginFails(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "BEGIN"}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "postgres",
ContinueOnError: true,
}, nil)
if err == nil {
t.Fatal("expected failed automatic batch transaction to stop execution")
}
if result.Executed != 0 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("batch SQL must not run after START TRANSACTION fails, got %d calls", fakeDB.batchCalls)
}
wantQueries := []string{"BEGIN", "ROLLBACK"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("failed batch BEGIN was replayed or left dirty: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
}
func TestExecuteSQLFileStreamTreatsCancelledBatchAsCancellationWithoutReplay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
fakeDB := &fakeSQLFileBatchDB{
failBatch: true,
onBatch: cancel,
}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(ctx, fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "postgres",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
ContinueOnError: true,
}, nil)
if err == nil || err.Error() != "已取消" {
t.Fatalf("expected cancellation, got %v", err)
}
if result.Executed != 0 || result.Failed != 0 {
t.Fatalf("cancellation must not be counted as a SQL failure, got %#v", result)
}
if fakeDB.batchCalls != 1 {
t.Fatalf("expected one interrupted batch attempt, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls != 2 {
t.Fatalf("expected only transaction begin and rollback, got %d calls: %#v", fakeDB.execCalls, fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamMarksInFlightStatementCancellationUnknown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
fakeDB := &fakeSQLFileBatchDB{execError: func(query string) error {
if strings.Contains(query, "INSERT INTO demo") {
cancel()
return context.Canceled
}
return nil
}}
result, err := executeSQLFileStream(ctx, fakeDB, strings.NewReader("INSERT INTO demo(id) VALUES (1);"), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation, got %v", err)
}
if !result.OutcomeUnknown || result.Executed != 0 || result.Failed != 0 {
t.Fatalf("in-flight cancellation must retain unknown commit outcome: %#v", result)
}
}
func TestExecuteSQLFileStreamDiscardsSuccessfulSessionToPreventStateLeak(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader("USE tenant_b;"), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil || result.Executed != 1 || result.Failed != 0 {
t.Fatalf("successful session-scoped statement failed: result=%#v err=%v", result, err)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("successful SQL-file session must be discarded before returning to the pool: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamRollsBackOpenUserTransactionAfterStatementError(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "INSERT INTO broken"}
input := strings.Join([]string{
"START TRANSACTION;",
"INSERT INTO broken(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected stop-on-error sentinel, got %v", err)
}
if result.Executed != 1 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{"START TRANSACTION", "INSERT INTO broken(id) VALUES (1)", "ROLLBACK"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("open transaction was not rolled back: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.closed || !fakeDB.session.discarded {
t.Fatalf("an interrupted import session must be discarded after rollback: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamDiscardsSessionAfterErrorWithoutTrackedTransaction(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "CREATE TABLE broken"}
_, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader("CREATE TABLE broken(id INT);"), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected stop-on-error sentinel, got %v", err)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("aborted SQL-file sessions may retain autocommit or other session state and must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamDiscardsSessionWhenOpenTransactionRollbackFails(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{
execError: func(query string) error {
if strings.Contains(query, "INSERT INTO broken") || query == "ROLLBACK" {
return errors.New("forced execution failure")
}
return nil
},
}
input := "START TRANSACTION;\nINSERT INTO broken(id) VALUES (1);"
_, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected stop-on-error sentinel, got %v", err)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("rollback failure must discard then close the session: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamRejectsUnclosedUserTransactionAtEndOfFile(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "START TRANSACTION;\nINSERT INTO demo(id) VALUES (1);"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected unclosed transaction to fail the import, got %v", err)
}
if result.Executed != 2 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{"START TRANSACTION", "INSERT INTO demo(id) VALUES (1)", "ROLLBACK"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("unclosed transaction was not rolled back: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
}
func TestExecuteSQLFileStreamDoesNotTreatOracleAnonymousBlockAsOpenTransaction(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
block := strings.Join([]string{
"BEGIN",
" NULL;",
"END;",
}, "\n")
input := block + "\n/\nSELECT 1 FROM dual;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "oracle",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("Oracle anonymous block must not leave a synthetic transaction open: %v", err)
}
if result.Executed != 2 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{block, "SELECT 1 FROM dual"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("anonymous block execution changed unexpectedly: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.closed || !fakeDB.session.discarded {
t.Fatalf("SQL-file session must be discarded even after a successful anonymous block: %#v", fakeDB.session)
}
}
func TestUpdateSQLFileTransactionStateDistinguishesBlocksFromTransactions(t *testing.T) {
tests := []struct {
name string
dbType string
inTransaction bool
stmt string
want bool
}{
{name: "mysql bare begin", dbType: "mysql", stmt: "BEGIN", want: true},
{name: "mysql begin work", dbType: "mysql", stmt: "BEGIN WORK", want: true},
{name: "mariadb anonymous block", dbType: "mariadb", stmt: "BEGIN NOT ATOMIC\n SET @value = 1;\nEND", want: false},
{name: "postgres begin work", dbType: "postgres", stmt: "BEGIN WORK", want: true},
{name: "postgres deferrable", dbType: "postgres", stmt: "BEGIN DEFERRABLE", want: true},
{name: "postgres not deferrable", dbType: "postgres", stmt: "BEGIN NOT DEFERRABLE", want: true},
{name: "postgres family oracle compatible block", dbType: "kingbase", stmt: "BEGIN\n NULL;\nEND", want: false},
{name: "oracle anonymous block", dbType: "oracle", stmt: "BEGIN\n NULL;\nEND", want: false},
{name: "oracle block preserves active transaction", dbType: "oracle", inTransaction: true, stmt: "BEGIN\n NULL;\nEND", want: true},
{name: "dameng anonymous block", dbType: "dameng", stmt: "BEGIN\n NULL;\nEND", want: false},
{name: "sqlserver control block", dbType: "sqlserver", stmt: "BEGIN\n PRINT 'done';\nEND", want: false},
{name: "sqlserver try block", dbType: "sqlserver", stmt: "BEGIN TRY\n SELECT 1;\nEND TRY", want: false},
{name: "sqlserver dialog", dbType: "sqlserver", stmt: "BEGIN DIALOG CONVERSATION @handle", want: false},
{name: "sqlserver transaction", dbType: "sqlserver", stmt: "BEGIN TRANSACTION", want: true},
{name: "sqlserver tran alias", dbType: "sqlserver", stmt: "BEGIN TRAN", want: true},
{name: "sqlserver distributed transaction", dbType: "sqlserver", stmt: "BEGIN DISTRIBUTED TRANSACTION", want: true},
{name: "sqlite deferred", dbType: "sqlite", stmt: "BEGIN DEFERRED", want: true},
{name: "sqlite immediate", dbType: "sqlite", stmt: "BEGIN IMMEDIATE", want: true},
{name: "sqlite exclusive", dbType: "sqlite", stmt: "BEGIN EXCLUSIVE TRANSACTION", want: true},
{name: "unknown ansi atomic block", dbType: "custom", stmt: "BEGIN ATOMIC\n VALUES 1;\nEND", want: false},
{name: "leading comment before transaction", dbType: "postgres", stmt: "-- restore transaction\nBEGIN TRANSACTION", want: true},
{name: "leading hash comment before mysql transaction", dbType: "mysql", stmt: "# restore transaction\nBEGIN", want: true},
{name: "unrelated start preserves active transaction", dbType: "mysql", inTransaction: true, stmt: "START REPLICA", want: true},
{name: "rollback to savepoint", dbType: "postgres", inTransaction: true, stmt: "ROLLBACK WORK TO SAVEPOINT before_import", want: true},
{name: "rollback to savepoint with comment", dbType: "sqlite", inTransaction: true, stmt: "ROLLBACK /* keep outer transaction */ TRANSACTION TO before_import", want: true},
{name: "commit and chain", dbType: "mysql", inTransaction: true, stmt: "COMMIT WORK AND CHAIN", want: true},
{name: "commit and no chain", dbType: "mysql", inTransaction: true, stmt: "COMMIT AND NO CHAIN", want: false},
{name: "rollback and chain", dbType: "postgres", inTransaction: true, stmt: "ROLLBACK AND CHAIN", want: true},
{name: "postgres end transaction", dbType: "postgres", inTransaction: true, stmt: "END TRANSACTION", want: false},
{name: "sqlite end transaction", dbType: "sqlite", inTransaction: true, stmt: "END TRANSACTION", want: false},
{name: "postgres abort", dbType: "postgres", inTransaction: true, stmt: "ABORT", want: false},
{name: "duckdb abort", dbType: "duckdb", inTransaction: true, stmt: "ABORT", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := updateSQLFileTransactionState(test.dbType, test.inTransaction, test.stmt); got != test.want {
t.Fatalf("transaction state = %v, want %v", got, test.want)
}
})
}
}
func TestExecuteSQLFileStreamHandlesSQLServerBlocksAndTransactions(t *testing.T) {
t.Run("control block", func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
block := "BEGIN\n PRINT 'done';\nEND"
input := block + ";\nSELECT 1;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("SQL Server control block must not leave a synthetic transaction open: %v", err)
}
if result.Executed != 2 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{block + ";", "SELECT 1"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("control block execution changed unexpectedly: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
})
t.Run("explicit transaction", func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "BEGIN TRAN;\nUPDATE demo SET value = 2;\nCOMMIT;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("SQL Server explicit transaction should complete normally: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{"BEGIN TRAN", "UPDATE demo SET value = 2", "COMMIT"}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("explicit transaction split changed unexpectedly: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
})
}
func TestExecuteSQLFileStreamDoesNotReuseSQLServerSessionWithNestedTransactionOpen(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"BEGIN TRAN;",
"BEGIN TRAN;",
"INSERT INTO demo(id) VALUES (1);",
"COMMIT;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("nested SQL Server transaction left open at EOF must fail, got %v", err)
}
if result.Executed != 4 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{
"BEGIN TRAN",
"BEGIN TRAN",
"INSERT INTO demo(id) VALUES (1)",
"COMMIT",
"ROLLBACK TRANSACTION",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("remaining nested transaction was not rolled back: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("nested transaction session must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamDoesNotTreatSQLServerNamedRollbackAsTransactionEnd(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"BEGIN TRAN;",
"SAVE TRANSACTION before_import;",
"ROLLBACK TRANSACTION before_import;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("named SQL Server rollback has ambiguous savepoint semantics and must keep cleanup active, got %v", err)
}
if result.Executed != 3 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{
"BEGIN TRAN",
"SAVE TRANSACTION before_import",
"ROLLBACK TRANSACTION before_import",
"ROLLBACK TRANSACTION",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("named rollback session was not cleaned conservatively: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("named rollback session must not be reused: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamClosesSQLServerNamedOuterTransactionRollback(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"BEGIN TRANSACTION import_work;",
"INSERT INTO demo(id) VALUES (1);",
"ROLLBACK TRANSACTION import_work;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("rollback to the tracked outer transaction name must close it: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("dedicated SQL-file session must be discarded after named rollback: %#v", fakeDB.session)
}
if len(fakeDB.execQueries) != 3 {
t.Fatalf("named outer rollback must not trigger an extra cleanup rollback: %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamKeepsCaseDistinctSQLServerSavepointTransactionOpen(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"BEGIN TRANSACTION ImportWork;",
"SAVE TRANSACTION importwork;",
"ROLLBACK TRANSACTION importwork;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("case-distinct savepoint rollback must leave the outer transaction open: %v", err)
}
if result.Executed != 3 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.execQueries[len(fakeDB.execQueries)-1] != "ROLLBACK TRANSACTION" {
t.Fatalf("outer transaction was not cleaned up: %#v", fakeDB.execQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("savepoint rollback session must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamClosesSQLServerTransactionAfterNamedRollbackAndCommit(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"BEGIN TRAN;",
"SAVE TRANSACTION before_import;",
"ROLLBACK TRANSACTION before_import;",
"COMMIT;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "sqlserver",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("final COMMIT should close the transaction retained after named rollback: %v", err)
}
if result.Executed != 4 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("dedicated SQL-file session must be discarded after commit: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamKeepsTransactionOpenWhenFinishStatementFailsInContinueMode(t *testing.T) {
tests := []struct {
name string
finishSQL string
}{
{name: "commit fails", finishSQL: "COMMIT"},
{name: "rollback fails", finishSQL: "ROLLBACK"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: test.finishSQL}
input := strings.Join([]string{
"START TRANSACTION;",
"INSERT INTO demo(id) VALUES (1);",
test.finishSQL + ";",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("failed transaction finish must leave cleanup active, got %v", err)
}
if result.Executed != 2 || result.Failed != 2 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if !result.OutcomeUnknown {
t.Fatalf("failed user %s after dispatch must retain an unknown commit outcome: %#v", test.finishSQL, result)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("unexpected cleanup state: %#v", fakeDB.session)
}
if fakeDB.execQueries[len(fakeDB.execQueries)-1] != "ROLLBACK" {
t.Fatalf("expected final cleanup rollback, got %#v", fakeDB.execQueries)
}
})
}
}
func TestExecuteSQLFileStreamMarksCancelledUserTransactionFinishUnknown(t *testing.T) {
for _, finishSQL := range []string{"COMMIT", "ROLLBACK"} {
t.Run(strings.ToLower(finishSQL), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
fakeDB := &fakeSQLFileBatchDB{execError: func(query string) error {
if query == finishSQL {
cancel()
return context.Canceled
}
return nil
}}
input := "START TRANSACTION;\nINSERT INTO demo(id) VALUES (1);\n" + finishSQL + ";"
result, err := executeSQLFileStream(ctx, fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled %s returned %v", finishSQL, err)
}
if !result.OutcomeUnknown || result.Executed != 2 || result.Failed != 0 {
t.Fatalf("cancelled %s after dispatch must retain an unknown outcome: %#v", finishSQL, result)
}
})
}
}
func TestExecuteSQLFileStreamDoesNotOpenTransactionWhenStartFailsInContinueMode(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "START TRANSACTION"}
input := "START TRANSACTION;\nCREATE TABLE demo(id INT);"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("failed START must not create a synthetic unclosed transaction: %v", err)
}
if result.Executed != 1 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if len(fakeDB.execQueries) != 2 {
t.Fatalf("failed START unexpectedly triggered cleanup: %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamPreservesMySQLAutocommitOffRollbackSemantics(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"SET autocommit=0;",
"INSERT INTO demo(id) VALUES (1);",
"ROLLBACK;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("autocommit-controlled rollback should complete normally: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("autocommit=0 DML must not be wrapped in an auto-committed batch: %d batch calls", fakeDB.batchCalls)
}
wantQueries := []string{
"SET autocommit=0",
"INSERT INTO demo(id) VALUES (1)",
"ROLLBACK",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("autocommit-controlled transaction semantics changed: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("session left with autocommit=0 must be discarded after import: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamPreservesMariaDBAutocommitOffRollbackSemantics(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "SET autocommit=0;\nINSERT INTO demo(id) VALUES (1);\nROLLBACK;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mariadb",
ContinueOnError: false,
}, nil)
if err != nil || result.Executed != 3 || result.Failed != 0 {
t.Fatalf("MariaDB autocommit-controlled rollback failed: result=%#v err=%v", result, err)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("MariaDB autocommit=0 DML must not be auto-committed in a batch: %d calls", fakeDB.batchCalls)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("MariaDB session left with autocommit=0 must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamRollsBackUnfinishedMySQLAutocommitOffWorkAtEOF(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"SET autocommit=0;",
"INSERT INTO demo(id) VALUES (1);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("unfinished autocommit=0 work must fail at EOF, got %v", err)
}
if result.Executed != 2 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{
"SET autocommit=0",
"INSERT INTO demo(id) VALUES (1)",
"ROLLBACK",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("unfinished autocommit=0 work was not rolled back: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("unfinished autocommit=0 session must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamRecognizesMySQLDumpCompositeAutocommitOff(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"SET @OLD_AUTOCOMMIT=@@AUTOCOMMIT, AUTOCOMMIT=0;",
"INSERT INTO demo(id) VALUES (1);",
"ROLLBACK;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("dump-style autocommit-controlled rollback should complete normally: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("composite AUTOCOMMIT=0 must disable automatic batching: %d batch calls", fakeDB.batchCalls)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("session left with dump-controlled autocommit must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamDiscardsSessionAfterMySQLAutocommitVariableRestore(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader("SET AUTOCOMMIT=@OLD_AUTOCOMMIT;"), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("variable-based autocommit restore should execute normally: %v", err)
}
if result.Executed != 1 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("unknown restored autocommit state must not return to the pool: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamRecognizesMySQLAutocommitEnableImplicitCommit(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"SET AUTOCOMMIT=0;",
"START TRANSACTION;",
"INSERT INTO demo(id) VALUES (1);",
"SET AUTOCOMMIT=1;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("enabling autocommit after an explicit transaction must commit it: %v", err)
}
if result.Executed != 4 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("autocommit-controlled DML must remain sequential: %d batch calls", fakeDB.batchCalls)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("dedicated SQL-file session must be discarded after restoring autocommit: %#v", fakeDB.session)
}
if strings.Contains(fmt.Sprint(fakeDB.execQueries), "ROLLBACK") {
t.Fatalf("SET AUTOCOMMIT=1 already committed the transaction: %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamRecognizesMySQLFamilyDDLImplicitCommit(t *testing.T) {
for _, dbType := range []string{"mysql", "mariadb"} {
t.Run(dbType, func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"START TRANSACTION;",
"INSERT INTO demo(id) VALUES (1);",
"CREATE TABLE demo_copy(id INT);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: dbType,
ContinueOnError: false,
}, nil)
if err != nil {
t.Fatalf("DDL implicit commit must close the tracked transaction: %v", err)
}
if result.Executed != 3 || result.Failed != 0 {
t.Fatalf("unexpected execution counters: %#v", result)
}
wantQueries := []string{
"START TRANSACTION",
"INSERT INTO demo(id) VALUES (1)",
"CREATE TABLE demo_copy(id INT)",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("successful DDL triggered a synthetic EOF rollback: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
})
}
}
func TestExecuteSQLFileStreamRecognizesMySQLDDLPreCommitWhenDDLAttemptFails(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "CREATE TABLE broken"}
input := strings.Join([]string{
"START TRANSACTION;",
"INSERT INTO demo(id) VALUES (1);",
"CREATE TABLE broken(id INT);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("failed DDL must not leave a synthetic transaction open after its pre-commit: %v", err)
}
if result.Executed != 2 || result.Failed != 1 {
t.Fatalf("DDL failure must be counted exactly once: %#v", result)
}
wantQueries := []string{
"START TRANSACTION",
"INSERT INTO demo(id) VALUES (1)",
"CREATE TABLE broken(id INT)",
}
if fmt.Sprint(fakeDB.execQueries) != fmt.Sprint(wantQueries) {
t.Fatalf("failed DDL triggered an invalid EOF rollback: got %#v want %#v", fakeDB.execQueries, wantQueries)
}
}
func TestExecuteSQLFileStreamDoesNotTreatMySQLTemporaryTableDDLAsImplicitCommit(t *testing.T) {
for _, ddl := range []string{
"CREATE TEMPORARY TABLE temp_import(id INT)",
"DROP TEMPORARY TABLE temp_import",
} {
t.Run(strings.Fields(ddl)[0], func(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "START TRANSACTION;\n" + ddl + ";"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("temporary-table DDL must leave the explicit transaction open, got %v", err)
}
if result.Executed != 2 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.execQueries[len(fakeDB.execQueries)-1] != "ROLLBACK" {
t.Fatalf("temporary-table transaction was not rolled back: %#v", fakeDB.execQueries)
}
})
}
}
func TestSQLFileMySQLImplicitCommitClassificationAvoidsConditionalFalsePositives(t *testing.T) {
tests := []struct {
name string
stmt string
want bool
}{
{name: "set password", stmt: "SET PASSWORD FOR 'app'@'%' = 'secret'", want: true},
{name: "reset replica", stmt: "RESET REPLICA ALL", want: true},
{name: "reset persist exception", stmt: "RESET PERSIST IF EXISTS max_connections", want: false},
{name: "lock tables", stmt: "LOCK TABLES demo WRITE", want: true},
{name: "lock instance is not table lock", stmt: "LOCK INSTANCE FOR BACKUP", want: false},
{name: "conditional unlock tables", stmt: "UNLOCK TABLES", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := sqlFileMySQLImplicitCommitBeforeStatement("mysql", test.stmt); got != test.want {
t.Fatalf("sqlFileMySQLImplicitCommitBeforeStatement(%q) = %v, want %v", test.stmt, got, test.want)
}
})
}
}
func TestExecuteSQLFileStreamDoesNotAssumeUnmatchedMySQLUnlockCommits(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "START TRANSACTION;\nINSERT INTO demo(id) VALUES (1);\nUNLOCK TABLES;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("UNLOCK TABLES without a tracked table lock must not clear the transaction: %v", err)
}
if result.Executed != 3 || result.Failed != 1 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if fakeDB.execQueries[len(fakeDB.execQueries)-1] != "ROLLBACK" {
t.Fatalf("uncommitted work must be rolled back: %#v", fakeDB.execQueries)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("uncertain transaction session must be discarded: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamPreservesMySQLTableLocksUntilUnlock(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := "LOCK TABLES demo WRITE;\nINSERT INTO demo(id) VALUES (1);\nUNLOCK TABLES;"
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil || result.Executed != 3 || result.Failed != 0 {
t.Fatalf("tracked LOCK/UNLOCK TABLES sequence failed: result=%#v err=%v", result, err)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("automatic transaction batching would release LOCK TABLES: %d calls", fakeDB.batchCalls)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("dedicated SQL-file session must be discarded after unlocking tables: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamDiscardsMySQLSessionWithTableLocksAtEOF(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader("LOCK TABLES demo WRITE;"), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if err != nil || result.Executed != 1 || result.Failed != 0 {
t.Fatalf("LOCK TABLES execution failed: result=%#v err=%v", result, err)
}
if fakeDB.session == nil || !fakeDB.session.discarded || !fakeDB.session.closed {
t.Fatalf("session retaining table locks must not return to the pool: %#v", fakeDB.session)
}
}
func TestExecuteSQLFileStreamStopsAfterSingleStatementError(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "CREATE TABLE broken"}
input := strings.Join([]string{
"CREATE TABLE broken(id INT);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: false,
}, nil)
if !errors.Is(err, errSQLFileStoppedOnError) {
t.Fatalf("expected stop-on-error sentinel, got %v", err)
}
if result.Executed != 0 || result.Failed != 1 {
t.Fatalf("expected the first failed statement to stop execution, got %#v", result)
}
if fakeDB.batchCalls != 0 {
t.Fatalf("expected no later write batch, got %d batch calls", fakeDB.batchCalls)
}
if len(fakeDB.execQueries) != 1 || fakeDB.execQueries[0] != "CREATE TABLE broken(id INT)" {
t.Fatalf("expected only the failing statement to run, got %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamCapsRetainedErrorDetailsInContinueMode(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failExecSQL: "CREATE TABLE broken_"}
statements := make([]string, 25)
for index := range statements {
statements[index] = fmt.Sprintf("CREATE TABLE broken_%d(id INT);", index)
}
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(strings.Join(statements, "\n")), sqlFileExecutionOptions{
DBType: "mysql",
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 0 || result.Failed != 25 {
t.Fatalf("unexpected execution counters: %#v", result)
}
if len(result.Errors) != sqlFileMaxErrorDetails {
t.Fatalf("retained %d error details, want cap %d", len(result.Errors), sqlFileMaxErrorDetails)
}
}
func TestExecuteSQLFileStreamDoesNotRetryFailedOversizedStatement(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failBatch: true}
largeValue := strings.Repeat("x", 256)
input := fmt.Sprintf("INSERT INTO demo(value) VALUES ('%s');", largeValue)
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "postgres",
BatchMaxStatements: 100,
BatchMaxBytes: 64,
ContinueOnError: true,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 0 || result.Failed != 1 {
t.Fatalf("expected the oversized statement failure to be recorded once, got %#v", result)
}
if fakeDB.batchCalls != 1 {
t.Fatalf("expected one oversized statement attempt, got %d", fakeDB.batchCalls)
}
if len(fakeDB.execQueries) != 2 || fakeDB.execQueries[0] != "BEGIN" || fakeDB.execQueries[1] != "ROLLBACK" {
t.Fatalf("expected no second execution of the oversized statement, got %#v", fakeDB.execQueries)
}
}
func TestExecuteSQLFileStreamUsesLocalizedStatementFailure(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{failBatch: true, failExecSQL: "VALUES (2)"}
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
ContinueOnError: true,
Text: func(key string, params map[string]any) string {
if key != "file.backend.message.statement_failed" {
t.Fatalf("unexpected i18n key %q", key)
}
return fmt.Sprintf("localized statement %v failed: %v SQL=%v", params["index"], params["detail"], params["sql"])
},
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if len(result.Errors) != 1 {
t.Fatalf("expected one localized statement error, got %#v", result.Errors)
}
if !strings.Contains(result.Errors[0], "localized statement 2 failed") || !strings.Contains(result.Errors[0], "VALUES (?)") {
t.Fatalf("expected localized per-statement error with redacted SQL snippet, got %#v", result.Errors)
}
if strings.Contains(result.Errors[0], "VALUES (2)") {
t.Fatalf("expected statement failure to omit SQL literal values, got %#v", result.Errors)
}
}
func TestExecuteSQLFileStreamDoesNotBatchSessionControlStatements(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"SET FOREIGN_KEY_CHECKS=0;",
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"CREATE TABLE demo2(id INT);",
"INSERT INTO demo2(id) VALUES (3);",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 5 || result.Failed != 0 {
t.Fatalf("expected 5 executed and 0 failed, got %#v", result)
}
if fakeDB.batchCalls != 2 {
t.Fatalf("expected two DML batch calls split by control/DDL statements, got %d", fakeDB.batchCalls)
}
if fakeDB.execCalls != 6 {
t.Fatalf("expected SET, CREATE, and transaction wrappers to execute sequentially, got %d", fakeDB.execCalls)
}
if fakeDB.execQueries[0] != "SET FOREIGN_KEY_CHECKS=0" || fakeDB.execQueries[3] != "CREATE TABLE demo2(id INT)" {
t.Fatalf("unexpected sequential statements: %#v", fakeDB.execQueries)
}
}
type chunkedReader struct {
data []byte
step int
}
func (r *chunkedReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := r.step
if n <= 0 || n > len(r.data) {
n = len(r.data)
}
if n > len(p) {
n = len(p)
}
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
func TestStreamSQLFileHandlesLongSingleLineAcrossChunks(t *testing.T) {
longValue := strings.Repeat("x", 5*1024*1024)
input := fmt.Sprintf("INSERT INTO demo(value) VALUES ('%s');SELECT 1;", longValue)
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 257}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 2 || len(statements) != 2 {
t.Fatalf("expected 2 statements, got count=%d statements=%d", count, len(statements))
}
if !strings.HasPrefix(statements[0], "INSERT INTO demo(value)") {
t.Fatalf("expected first statement to be insert, got %.80q", statements[0])
}
if statements[1] != "SELECT 1" {
t.Fatalf("expected second statement SELECT 1, got %q", statements[1])
}
}
func TestStreamSQLFileHandlesSplitTokenBoundaries(t *testing.T) {
input := strings.Join([]string{
"SELECT 1 -- comment; still comment",
"SELECT 'it''s ok';",
"SELECT $tag$hello;world$tag$;",
"SELECT 2",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 1}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 3 || len(statements) != 3 {
t.Fatalf("expected 3 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != "SELECT 1 -- comment; still comment\nSELECT 'it''s ok'" {
t.Fatalf("unexpected first statement: %q", statements[0])
}
if statements[1] != "SELECT $tag$hello;world$tag$" {
t.Fatalf("unexpected dollar-quoted statement: %q", statements[1])
}
if statements[2] != "SELECT 2" {
t.Fatalf("unexpected full-width semicolon statement: %q", statements[2])
}
}
func TestStreamSQLFileKeepsOracleAnonymousBlockTogether(t *testing.T) {
input := strings.Join([]string{
"BEGIN",
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
"END;",
"SELECT 1 FROM dual;",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 3}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 2 || len(statements) != 2 {
t.Fatalf("expected 2 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != strings.Join([]string{
"BEGIN",
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
"END;",
}, "\n") {
t.Fatalf("unexpected anonymous block statement: %q", statements[0])
}
if statements[1] != "SELECT 1 FROM dual" {
t.Fatalf("unexpected second statement: %q", statements[1])
}
}
func TestStreamSQLFileKeepsOracleCreateProcedureTogether(t *testing.T) {
input := strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_tally2accept(",
" p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE,",
" out_acceptno OUT t_accept_h.acceptno%TYPE",
") IS",
" v_busno t_tally_accept_h.busno%TYPE;",
" v_count PLS_INTEGER;",
"BEGIN",
" SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;",
" IF v_count > 0 THEN",
" out_acceptno := p_tallyacceptno;",
" END IF;",
"END;",
"SELECT 1 FROM dual;",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 5}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 2 || len(statements) != 2 {
t.Fatalf("expected 2 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_tally2accept(",
" p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE,",
" out_acceptno OUT t_accept_h.acceptno%TYPE",
") IS",
" v_busno t_tally_accept_h.busno%TYPE;",
" v_count PLS_INTEGER;",
"BEGIN",
" SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;",
" IF v_count > 0 THEN",
" out_acceptno := p_tallyacceptno;",
" END IF;",
"END;",
}, "\n") {
t.Fatalf("unexpected create procedure statement: %q", statements[0])
}
if statements[1] != "SELECT 1 FROM dual" {
t.Fatalf("unexpected second statement: %q", statements[1])
}
}
func TestStreamSQLFileKeepsOracleCreateProcedureCursorCaseExpressionTogether(t *testing.T) {
input := strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_accept_to_add(",
" p_acceptno IN t_accept_h.acceptno%TYPE",
") IS",
" CURSOR cur_store_same(p_ind s_sys_ini.inipara%TYPE) IS",
" SELECT si.compid, si.batid, si.wareid",
" FROM t_store_i si",
" ORDER BY CASE",
" WHEN p_ind = '1' THEN",
" to_char(si.invalidate - to_date('19700101', 'yyyymmdd'))",
" WHEN p_ind = '2' THEN",
" lpad(to_char(floor(si.wareqty)), 10, '0')",
" ELSE",
" to_char(si.batid)",
" END,si.batid;",
"BEGIN",
" NULL;",
"END;",
"/",
"SELECT 1 FROM dual;",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 4}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 2 || len(statements) != 2 {
t.Fatalf("expected 2 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_accept_to_add(",
" p_acceptno IN t_accept_h.acceptno%TYPE",
") IS",
" CURSOR cur_store_same(p_ind s_sys_ini.inipara%TYPE) IS",
" SELECT si.compid, si.batid, si.wareid",
" FROM t_store_i si",
" ORDER BY CASE",
" WHEN p_ind = '1' THEN",
" to_char(si.invalidate - to_date('19700101', 'yyyymmdd'))",
" WHEN p_ind = '2' THEN",
" lpad(to_char(floor(si.wareqty)), 10, '0')",
" ELSE",
" to_char(si.batid)",
" END,si.batid;",
"BEGIN",
" NULL;",
"END;",
}, "\n") {
t.Fatalf("unexpected create procedure statement: %q", statements[0])
}
if statements[1] != "SELECT 1 FROM dual" {
t.Fatalf("unexpected second statement: %q", statements[1])
}
}
func TestStreamSQLFileSkipsOracleSqlPlusSlashDelimiter(t *testing.T) {
input := strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_tally2accept(",
" p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE",
") IS",
" v_count PLS_INTEGER;",
"BEGIN",
" SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;",
"END;",
"/",
"SELECT 1 FROM dual;",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 2}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 2 || len(statements) != 2 {
t.Fatalf("expected 2 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != strings.Join([]string{
"CREATE OR REPLACE PROCEDURE proc_tally2accept(",
" p_tallyacceptno IN t_tally_accept_h.acceptno%TYPE",
") IS",
" v_count PLS_INTEGER;",
"BEGIN",
" SELECT COUNT(*) INTO v_count FROM t_tally_accept_h WHERE acceptno = p_tallyacceptno;",
"END;",
}, "\n") {
t.Fatalf("unexpected create procedure statement: %q", statements[0])
}
if statements[1] != "SELECT 1 FROM dual" {
t.Fatalf("unexpected second statement: %q", statements[1])
}
}
func TestStreamSQLFileKeepsOraclePackageSpecAndBodyTogether(t *testing.T) {
input := strings.Join([]string{
"CREATE OR REPLACE PACKAGE pkg_order AS",
" PROCEDURE sync_order(p_id IN NUMBER);",
"END pkg_order;",
"/",
"CREATE OR REPLACE PACKAGE BODY pkg_order AS",
" PROCEDURE sync_order(p_id IN NUMBER) IS",
" BEGIN",
" NULL;",
" END sync_order;",
"END pkg_order;",
"/ -- SQLPlus delimiter from PL/SQL tools",
"SELECT 1 FROM dual;",
}, "\n")
var statements []string
count, err := streamSQLFile(&chunkedReader{data: []byte(input), step: 3}, func(index int, stmt string) error {
statements = append(statements, stmt)
return nil
})
if err != nil {
t.Fatalf("streamSQLFile returned error: %v", err)
}
if count != 3 || len(statements) != 3 {
t.Fatalf("expected 3 statements, got count=%d statements=%#v", count, statements)
}
if statements[0] != strings.Join([]string{
"CREATE OR REPLACE PACKAGE pkg_order AS",
" PROCEDURE sync_order(p_id IN NUMBER);",
"END pkg_order;",
}, "\n") {
t.Fatalf("unexpected package spec statement: %q", statements[0])
}
if statements[1] != strings.Join([]string{
"CREATE OR REPLACE PACKAGE BODY pkg_order AS",
" PROCEDURE sync_order(p_id IN NUMBER) IS",
" BEGIN",
" NULL;",
" END sync_order;",
"END pkg_order;",
}, "\n") {
t.Fatalf("unexpected package body statement: %q", statements[1])
}
if statements[2] != "SELECT 1 FROM dual" {
t.Fatalf("unexpected third statement: %q", statements[2])
}
}
func TestResolveSQLFileExecutionRunConfigUsesServerConnectionForGoNaviMySQLDatabaseBackup(t *testing.T) {
preamble := strings.Join([]string{
"-- GoNavi SQL Export",
"-- Time: 2026-07-17 00:00:00",
"-- Database: restore_target",
"",
"CREATE DATABASE IF NOT EXISTS `restore_target`;",
"",
"USE `restore_target`;",
}, "\n")
got := resolveSQLFileExecutionRunConfig(
connection.ConnectionConfig{Type: "mysql", Database: "selected_target"},
"selected_target",
[]byte(preamble),
)
if got.Database != "" {
t.Fatalf("GoNavi MySQL database backup must connect at server level before CREATE/USE, got database=%q", got.Database)
}
}
func TestResolveSQLFileExecutionRunConfigKeepsSelectedDatabaseForRegularSQL(t *testing.T) {
got := resolveSQLFileExecutionRunConfig(
connection.ConnectionConfig{Type: "mysql", Database: "configured_default"},
"selected_target",
[]byte("CREATE TABLE demo(id INT);"),
)
if got.Database != "selected_target" {
t.Fatalf("regular SQL must retain the selected database, got database=%q", got.Database)
}
}
func TestResolveSQLFileExecutionRunConfigUsesServerConnectionForLegacyGoNaviMySQLDatabaseBackup(t *testing.T) {
preamble := strings.Join([]string{
"-- GoNavi SQL Export",
"-- Time: 2026-07-11 00:00:00",
"-- Database: legacy_restore_target",
"",
"USE `legacy_restore_target`;",
}, "\n")
got := resolveSQLFileExecutionRunConfig(
connection.ConnectionConfig{Type: "mysql", Database: "selected_target"},
"selected_target",
[]byte(preamble),
)
if got.Database != "" {
t.Fatalf("legacy GoNavi MySQL database backup must connect at server level before USE, got database=%q", got.Database)
}
}
func TestBuildGoNaviMySQLDatabaseBackupBootstrapSQLOnlyForLegacyBackup(t *testing.T) {
legacy := goNaviMySQLDatabaseBackupPreamble{databaseName: "legacy_restore_target"}
if got := buildGoNaviMySQLDatabaseBackupBootstrapSQL(legacy); got != "CREATE DATABASE IF NOT EXISTS `legacy_restore_target`" {
t.Fatalf("unexpected legacy bootstrap SQL: %q", got)
}
current := goNaviMySQLDatabaseBackupPreamble{
databaseName: "current_restore_target",
includesCreateDatabase: true,
}
if got := buildGoNaviMySQLDatabaseBackupBootstrapSQL(current); got != "" {
t.Fatalf("backup that already creates its database must not be bootstrapped again, got %q", got)
}
}
func TestExecuteSQLFileStreamRunsGoNaviMySQLDatabaseBackupHeader(t *testing.T) {
fakeDB := &fakeSQLFileBatchDB{}
input := strings.Join([]string{
"-- GoNavi SQL Export",
"-- Database: restore_target",
"CREATE DATABASE IF NOT EXISTS `restore_target`;",
"USE `restore_target`;",
"SET FOREIGN_KEY_CHECKS=0;",
"CREATE TABLE users(id INT PRIMARY KEY);",
"INSERT INTO users(id) VALUES (1);",
"SET FOREIGN_KEY_CHECKS=1;",
}, "\n")
result, err := executeSQLFileStream(context.Background(), fakeDB, strings.NewReader(input), sqlFileExecutionOptions{
DBType: "mysql",
BatchMaxStatements: 100,
BatchMaxBytes: 1024,
}, nil)
if err != nil {
t.Fatalf("executeSQLFileStream returned error: %v", err)
}
if result.Executed != 6 || result.Failed != 0 {
t.Fatalf("expected complete database backup header and statements to execute, got %#v", result)
}
joinedExec := strings.Join(fakeDB.execQueries, "\n")
for _, expected := range []string{
"CREATE DATABASE IF NOT EXISTS `restore_target`",
"USE `restore_target`",
"CREATE TABLE users(id INT PRIMARY KEY)",
"SET FOREIGN_KEY_CHECKS=1",
} {
if !strings.Contains(joinedExec, expected) {
t.Fatalf("expected backup statement %q to execute, queries=%#v", expected, fakeDB.execQueries)
}
}
if len(fakeDB.batchQueries) != 1 || !strings.Contains(fakeDB.batchQueries[0], "INSERT INTO users(id) VALUES (1)") {
t.Fatalf("expected INSERT data to be batched after schema restore, batches=%#v", fakeDB.batchQueries)
}
}
func TestImportDatabaseSQLHonorsConnectionProtections(t *testing.T) {
allowedFilePath := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(allowedFilePath, []byte("CREATE TABLE demo(id INT);"), 0o600); err != nil {
t.Fatalf("write SQL import fixture: %v", err)
}
missingFilePath := filepath.Join(t.TempDir(), "missing.sql")
tests := []struct {
name string
protection connection.ConnectionProtectionConfig
filePath string
wantBlock bool
}{
{
name: "data import restricted",
protection: connection.ConnectionProtectionConfig{RestrictDataImport: true},
filePath: missingFilePath,
wantBlock: true,
},
{
name: "structure edit restricted",
protection: connection.ConnectionProtectionConfig{RestrictStructureEdit: true},
filePath: missingFilePath,
wantBlock: true,
},
{
name: "script execution restricted",
protection: connection.ConnectionProtectionConfig{RestrictScriptExecution: true},
filePath: missingFilePath,
wantBlock: true,
},
{
name: "allowed",
filePath: allowedFilePath,
wantBlock: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
opened := false
fakeDB := &fakeSQLFileBatchDB{}
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return fakeDB, nil
}
app := NewApp()
app.configDir = t.TempDir()
result := app.ImportDatabaseSQL(connection.ConnectionConfig{
Type: "mysql",
Protection: test.protection,
}, "app", test.filePath, "database-import-protection-test", false)
if test.wantBlock {
if result.Success {
t.Fatalf("ImportDatabaseSQL unexpectedly succeeded: %#v", result)
}
wantMessage := readOnlyConnectionActionBlockedMessageWithText(
"connection.backend.action.import_data",
app.appText,
)
if result.Message != wantMessage {
t.Fatalf("blocked message = %q, want %q", result.Message, wantMessage)
}
if opened {
t.Fatal("ImportDatabaseSQL opened a database despite connection protection")
}
return
}
if !result.Success {
t.Fatalf("ImportDatabaseSQL returned failure: %#v", result)
}
if !opened {
t.Fatal("ImportDatabaseSQL did not open a database on the allowed path")
}
if len(fakeDB.execQueries) != 1 || fakeDB.execQueries[0] != "CREATE TABLE demo(id INT)" {
t.Fatalf("unexpected executed SQL: %#v", fakeDB.execQueries)
}
})
}
}
func TestImportDatabaseSQLFailsClosedWithoutPinnedSession(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "database.sql")
if err := os.WriteFile(filePath, []byte("CREATE TABLE demo(id INT);"), 0o600); err != nil {
t.Fatal(err)
}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
database := &fakeSQLFileUnpinnedDB{}
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
app := NewApp()
app.configDir = t.TempDir()
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql"},
"app",
filePath,
"database-import-unpinned-test",
false,
)
if result.Success || result.Message != app.appText("data_import.capability.reason.pinned_session_unavailable", nil) {
t.Fatalf("unexpected unpinned result: %#v", result)
}
if database.execCalls != 0 {
t.Fatalf("unpinned import executed %d statement(s)", database.execCalls)
}
}
func TestImportDatabaseSQLRejectsUnsupportedDialectBeforeFileAccess(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
opened := false
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeSQLFileUnpinnedDB{}, nil
}
app := NewApp()
app.configDir = t.TempDir()
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "future-db"},
"app",
filepath.Join(t.TempDir(), "missing.sql"),
"database-import-unsupported-test",
false,
)
if result.Success || result.Message != app.appText("data_import.capability.reason.database_type_unsupported", nil) {
t.Fatalf("unexpected unsupported-dialect result: %#v", result)
}
if opened {
t.Fatal("unsupported database import opened a database")
}
}
func TestExecuteSQLFileHonorsScriptExecutionProtection(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "script.sql")
if err := os.WriteFile(filePath, []byte("DROP TABLE users;"), 0o600); err != nil {
t.Fatal(err)
}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
opened := false
newDatabaseFunc = func(string) (db.Database, error) {
opened = true
return &fakeSQLFileBatchDB{}, nil
}
app := NewApp()
app.configDir = t.TempDir()
result := app.ExecuteSQLFile(connection.ConnectionConfig{
Type: "mysql",
Protection: connection.ConnectionProtectionConfig{
RestrictScriptExecution: true,
},
}, "app", filePath, "protected-script")
if result.Success {
t.Fatalf("protected SQL file unexpectedly succeeded: %#v", result)
}
if opened {
t.Fatal("protected SQL file opened a database")
}
}
func TestImportDatabaseSQLStopPolicyDoesNotReplayFailedBatch(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "database.sql")
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
if err := os.WriteFile(filePath, []byte(input), 0o600); err != nil {
t.Fatalf("write SQL import fixture: %v", err)
}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
fakeDB := &fakeSQLFileBatchDB{failBatch: true, failExecSQL: "VALUES (2)"}
newDatabaseFunc = func(string) (db.Database, error) {
return fakeDB, nil
}
app := NewApp()
app.configDir = t.TempDir()
result := app.ImportDatabaseSQL(connection.ConnectionConfig{Type: "mysql"}, "app", filePath, "database-import-stop-policy-test", false)
if result.Success {
t.Fatalf("ImportDatabaseSQL unexpectedly succeeded: %#v", result)
}
payload, ok := result.Data.(map[string]interface{})
if !ok {
t.Fatalf("result data type = %T, want map[string]interface{}", result.Data)
}
if payload["completed"] != false || payload["stoppedOnError"] != true {
t.Fatalf("unexpected stop-on-error payload: %#v", payload)
}
if fakeDB.batchCalls != 1 || fakeDB.execCalls != 2 {
t.Fatalf("failed database import replayed its batch: batchCalls=%d execCalls=%d queries=%#v", fakeDB.batchCalls, fakeDB.execCalls, fakeDB.execQueries)
}
}
func TestImportDatabaseSQLContinuePolicyCompletesWithRecordedErrors(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "database.sql")
input := strings.Join([]string{
"INSERT INTO demo(id) VALUES (1);",
"INSERT INTO demo(id) VALUES (2);",
"INSERT INTO demo(id) VALUES (3);",
}, "\n")
if err := os.WriteFile(filePath, []byte(input), 0o600); err != nil {
t.Fatalf("write SQL import fixture: %v", err)
}
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
fakeDB := &fakeSQLFileBatchDB{failBatch: true, failExecSQL: "VALUES (2)"}
newDatabaseFunc = func(string) (db.Database, error) {
return fakeDB, nil
}
app := NewApp()
app.configDir = t.TempDir()
result := app.ImportDatabaseSQL(connection.ConnectionConfig{Type: "mysql"}, "app", filePath, "database-import-continue-policy-test", true)
if result.Success {
t.Fatalf("backend result with statement errors should remain unsuccessful: %#v", result)
}
payload, ok := result.Data.(map[string]interface{})
if !ok {
t.Fatalf("result data type = %T, want map[string]interface{}", result.Data)
}
if payload["completed"] != true || payload["stoppedOnError"] != false || payload["failed"] != 1 {
t.Fatalf("unexpected completed-with-errors payload: %#v", payload)
}
if fakeDB.batchCalls != 0 || fakeDB.execCalls != 3 {
t.Fatalf("MySQL continue policy must execute safely without a replayable batch: batchCalls=%d execCalls=%d queries=%#v", fakeDB.batchCalls, fakeDB.execCalls, fakeDB.execQueries)
}
}