feat(i18n): 收口数据库驱动多语言代码

- 提交 internal/db 多驱动用户可见错误与状态文案多语言化

- 补齐数据库驱动多语言测试与六语言 catalog

- 修复 frontend i18n catalog 的 4 个失效 guard
This commit is contained in:
tianqijiuyun-latiao
2026-06-22 10:09:45 +08:00
parent 3006429a9a
commit d13c153f5e
55 changed files with 19286 additions and 216 deletions

View File

@@ -56,6 +56,9 @@ const readDataGridV2DdlWorkspaceSource = (): string =>
const readQueryEditorSource = (): string =>
readFileSync(new URL("../components/QueryEditor.tsx", import.meta.url), "utf8");
const readQueryEditorResultsPanelSource = (): string =>
readFileSync(new URL("../components/QueryEditorResultsPanel.tsx", import.meta.url), "utf8");
const readSqlDialectSource = (): string =>
readFileSync(new URL("../utils/sqlDialect.ts", import.meta.url), "utf8");
@@ -594,11 +597,13 @@ describe("i18n catalog", () => {
"data_grid.message.change_set_build_failed_detail",
"data_grid.message.preview_sql_failed_detail",
"data_grid.message.commit_failed",
"data_grid.message.rollback_failed",
];
const noPlaceholderKeys = [
"data_grid.message.change_set_build_failed",
"data_grid.message.preview_sql_failed",
"data_grid.message.transaction_committed",
"data_grid.message.transaction_rolled_back",
"data_grid.message.no_changes_to_commit",
"data_grid.message.copied_to_clipboard",
"data_grid.message.no_field_name",
@@ -639,6 +644,7 @@ describe("i18n catalog", () => {
}
expect(t("en-US", "data_grid.message.commit_failed", { detail: "<raw-detail>" })).toContain("<raw-detail>");
expect(t("en-US", "data_grid.message.rollback_failed", { detail: "<raw-rollback-detail>" })).toContain("<raw-rollback-detail>");
expect(t("zh-CN", "data_grid.message.preview_sql_failed_detail", { detail: "<raw-preview-error>" })).toContain("<raw-preview-error>");
expect(t("de-DE", "data_grid.copy_sql.error.missing_table_name", { mode: "UPDATE" })).toContain("UPDATE");
});
@@ -1990,7 +1996,7 @@ describe("i18n catalog", () => {
const aiContextSource = sliceBetween(
source,
"const buildQueryEditorAiContextPrompt = (connection: any, database: string): string => {",
"// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)",
"// HMR 重载时释放旧注册避免补全和 hover 内容重复",
);
for (const language of SUPPORTED_LANGUAGES) {
@@ -2126,8 +2132,8 @@ describe("i18n catalog", () => {
);
const diagnosePromptSource = sliceBetween(
source,
"const prompt = translate('query_editor.ai_prompt.diagnose', {",
"{translate('query_editor.result.ai_diagnose')}",
" const handleDiagnoseExecutionError = () => {",
" const sqlEditorTransactionToolbar = (",
);
const toolbarAndDiagnoseSource = `${toolbarPromptSource}\n${diagnosePromptSource}`;
@@ -2169,12 +2175,7 @@ describe("i18n catalog", () => {
sliceBetween(
source,
" // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding",
" // HMR 重载时释放旧注册避免补全项重复",
),
sliceBetween(
source,
" objectHoverActionRef.current?.dispose?.();",
" }, [languagePreference, showObjectInfoAtPosition]);",
" // HMR 重载或测试重置时,以全局状态为准,避免本地闭包状态和 provider 列表不同步。",
),
sliceBetween(
source,
@@ -2265,11 +2266,11 @@ describe("i18n catalog", () => {
"query_editor.empty_state.title",
"query_editor.empty_state.description",
] as const;
const source = readQueryEditorSource();
const source = readQueryEditorResultsPanelSource();
const emptyStateSource = sliceBetween(
source,
"<div className={isV2Ui ? 'gn-v2-query-empty' : undefined}",
" </div>\r\n\r\n <Modal",
" </>",
);
for (const language of SUPPORTED_LANGUAGES) {

View File

@@ -38,16 +38,16 @@ func execParameterizedInsertBatches(config parameterizedInsertConfig) error {
return nil
}
if strings.TrimSpace(config.Table) == "" {
return fmt.Errorf("表名不能为空")
return localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
if config.QuoteColumn == nil {
return fmt.Errorf("列名引用函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_quote_column_required", nil)
}
if config.Placeholder == nil {
return fmt.Errorf("占位符函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_placeholder_required", nil)
}
if config.Exec == nil {
return fmt.Errorf("执行函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_exec_required", nil)
}
if config.Value == nil {
config.Value = func(_ string, value interface{}) (interface{}, bool) { return value, false }
@@ -70,7 +70,7 @@ func execParameterizedInsertBatches(config parameterizedInsertConfig) error {
for range rows {
res, err := config.Exec(config.EmptyInsertSQL(config.Table))
if err != nil {
return fmt.Errorf("插入失败:%v", err)
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_failed", map[string]any{"detail": err.Error()})
}
if config.RequireAffected {
if err := requireInsertAffected(res); err != nil {
@@ -163,7 +163,7 @@ func execParameterizedInsertBatch(config parameterizedInsertConfig, rows []prepa
)
res, err := config.Exec(query, args...)
if err != nil {
return fmt.Errorf("插入失败:%v", err)
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_failed", map[string]any{"detail": err.Error()})
}
if config.RequireAffected {
if err := requireInsertAffected(res); err != nil {
@@ -178,7 +178,7 @@ func requireInsertAffected(result sql.Result) error {
return nil
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
return fmt.Errorf("插入未生效:未影响任何行")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_no_rows_affected", nil)
}
return nil
}
@@ -219,16 +219,16 @@ func execLiteralInsertBatches(config literalInsertConfig) error {
return nil
}
if strings.TrimSpace(config.Table) == "" {
return fmt.Errorf("表名不能为空")
return localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
if config.QuoteColumn == nil {
return fmt.Errorf("列名引用函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_quote_column_required", nil)
}
if config.Literal == nil {
return fmt.Errorf("字面量函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_literal_required", nil)
}
if config.Exec == nil {
return fmt.Errorf("执行函数不能为空")
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_exec_required", nil)
}
if config.RowSeparator == "" {
config.RowSeparator = ", "
@@ -282,7 +282,10 @@ func execLiteralInsertBatch(config literalInsertConfig, rows []preparedInsertRow
)
res, err := config.Exec(query)
if err != nil {
return fmt.Errorf("插入失败:%v; sql=%s", err, query)
return localizedDatabaseRuntimeError("db.backend.error.batch_insert_failed_with_sql", map[string]any{
"detail": err.Error(),
"sql": query,
})
}
if config.RequireAffected {
if err := requireInsertAffected(res); err != nil {

View File

@@ -3,9 +3,13 @@ package db
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
func TestExecParameterizedInsertBatchesGroupsRowsByColumnSet(t *testing.T) {
@@ -229,3 +233,218 @@ func TestExecParameterizedInsertBatchesRunsEmptyInsertSQLWhenAllColumnsOmitted(t
}
}
}
func TestBatchInsertErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
baseErr := errors.New("driver insert failed")
rows := []map[string]interface{}{{"id": 1}}
quoteColumn := func(column string) string { return `"` + column + `"` }
placeholder := func(int) string { return "?" }
cases := []struct {
name string
call func() error
want string
}{
{
name: "parameterized table name required",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: " ",
Rows: rows,
QuoteColumn: quoteColumn,
Placeholder: placeholder,
Exec: func(string, ...interface{}) (sql.Result, error) {
return driver.RowsAffected(1), nil
},
})
},
want: "Table name is required",
},
{
name: "parameterized quote function required",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: `"users"`,
Rows: rows,
Placeholder: placeholder,
Exec: func(string, ...interface{}) (sql.Result, error) {
return driver.RowsAffected(1), nil
},
})
},
want: "Column quoting function is required",
},
{
name: "parameterized placeholder function required",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Exec: func(string, ...interface{}) (sql.Result, error) {
return driver.RowsAffected(1), nil
},
})
},
want: "Placeholder function is required",
},
{
name: "parameterized exec function required",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Placeholder: placeholder,
})
},
want: "Execution function is required",
},
{
name: "parameterized insert failed keeps raw detail",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Placeholder: placeholder,
Exec: func(string, ...interface{}) (sql.Result, error) {
return nil, baseErr
},
})
},
want: "Insert failed: driver insert failed",
},
{
name: "parameterized insert no rows affected",
call: func() error {
return execParameterizedInsertBatches(parameterizedInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Placeholder: placeholder,
RequireAffected: true,
Exec: func(string, ...interface{}) (sql.Result, error) {
return driver.RowsAffected(0), nil
},
})
},
want: "Insert did not take effect: no rows were affected",
},
{
name: "literal function required",
call: func() error {
return execLiteralInsertBatches(literalInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Exec: func(string) (sql.Result, error) {
return driver.RowsAffected(1), nil
},
})
},
want: "Literal function is required",
},
{
name: "literal insert failed keeps raw detail and sql",
call: func() error {
return execLiteralInsertBatches(literalInsertConfig{
Table: `"users"`,
Rows: rows,
QuoteColumn: quoteColumn,
Literal: func(value interface{}) string { return fmt.Sprintf("%v", value) },
Exec: func(string) (sql.Result, error) {
return nil, baseErr
},
})
},
want: `Insert failed: driver insert failed; SQL=INSERT INTO "users" ("id") VALUES (1)`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected batch insert error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
for _, raw := range []string{"表名不能为空", "列名引用函数不能为空", "占位符函数不能为空", "执行函数不能为空", "字面量函数不能为空", "插入失败", "插入未生效"} {
if strings.Contains(err.Error(), raw) {
t.Fatalf("expected no raw Chinese batch insert text %q in %q", raw, err.Error())
}
}
})
}
}
func TestBatchInsertErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("batch_insert.go")
if err != nil {
t.Fatalf("read batch_insert.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
`fmt.Errorf("表名不能为空")`,
`fmt.Errorf("列名引用函数不能为空")`,
`fmt.Errorf("占位符函数不能为空")`,
`fmt.Errorf("执行函数不能为空")`,
`fmt.Errorf("字面量函数不能为空")`,
`fmt.Errorf("插入失败:%v", err)`,
`fmt.Errorf("插入失败:%v; sql=%s", err, query)`,
`fmt.Errorf("插入未生效:未影响任何行")`,
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("batch_insert.go still contains raw batch insert text %q", rawMessage)
}
}
for _, key := range []string{
"db.backend.error.table_name_required",
"db.backend.error.batch_insert_quote_column_required",
"db.backend.error.batch_insert_placeholder_required",
"db.backend.error.batch_insert_exec_required",
"db.backend.error.batch_insert_literal_required",
"db.backend.error.batch_insert_failed",
"db.backend.error.batch_insert_failed_with_sql",
"db.backend.error.batch_insert_no_rows_affected",
} {
if !strings.Contains(source, key) {
t.Fatalf("batch_insert.go does not reference i18n key %q", key)
}
}
}
func TestBatchInsertErrorCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.table_name_required",
"db.backend.error.batch_insert_quote_column_required",
"db.backend.error.batch_insert_placeholder_required",
"db.backend.error.batch_insert_exec_required",
"db.backend.error.batch_insert_literal_required",
"db.backend.error.batch_insert_failed",
"db.backend.error.batch_insert_failed_with_sql",
"db.backend.error.batch_insert_no_rows_affected",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing batch insert key %q", language, key)
}
}
}
}

View File

@@ -508,33 +508,55 @@ func sanitizeClickHouseErrorMessage(err error) string {
func clickHouseAttemptFailureMessage(protocol clickhouse.Protocol, err error) string {
if protocol == clickhouse.HTTP && isClickHouseHTTPClientProtocolVersionUnsupported(err) {
return "当前 ClickHouse HTTP 端口不支持 client_protocol_version(常见于 ClickHouse 22.8),将使用 HTTP 兼容模式重试;如仍失败请确认连接协议和端口"
return localizedDriverRuntimeText("db.backend.error.clickhouse_http_client_protocol_version_unsupported", nil)
}
if isClickHouseProtocolMismatch(err) {
if protocol == clickhouse.Native {
return "服务端响应不像 Native 握手,当前端口更像 HTTP/HTTPS 端口;请选择 HTTP 协议,或确认 ClickHouse Native 端口"
return localizedDriverRuntimeText("db.backend.error.clickhouse_native_protocol_mismatch", nil)
}
return "服务端响应不像 HTTP 响应,当前端口更像 Native 端口;请选择 Native 协议,或确认 ClickHouse HTTP 端口"
return localizedDriverRuntimeText("db.backend.error.clickhouse_http_protocol_mismatch", nil)
}
message := sanitizeClickHouseErrorMessage(err)
if message == "" {
return "未知错误"
return localizedDriverRuntimeText("db.backend.error.clickhouse_unknown_error", nil)
}
return message
}
func clickHouseTLSConfigFailedMessage(attempt int, protocol string, err error) string {
return localizedDriverRuntimeText("db.backend.error.clickhouse_attempt_tls_config_failed", map[string]any{
"attempt": attempt,
"protocol": protocol,
"detail": err,
})
}
func clickHouseAttemptValidationFailedMessage(attempt int, protocol string, detail string) string {
return localizedDriverRuntimeText("db.backend.error.clickhouse_attempt_validation_failed", map[string]any{
"attempt": attempt,
"protocol": protocol,
"detail": detail,
})
}
func clickHouseConnectFailureSummary(config connection.ConnectionConfig, failures []string) string {
protocolMode := normalizeClickHouseProtocol(config.ClickHouseProtocol)
detail := strings.Join(failures, "")
detail := strings.Join(failures, "; ")
if strings.TrimSpace(detail) == "" {
detail = "未获取到驱动返回的错误详情"
detail = localizedDriverRuntimeText("db.backend.error.clickhouse_driver_detail_missing", nil)
}
if protocolMode != clickHouseProtocolAuto {
return fmt.Sprintf("ClickHouse 连接验证失败:已按用户选择使用 %s 协议连接 %s:%d。%s",
strings.ToUpper(protocolMode), config.Host, config.Port, detail)
return localizedDriverRuntimeText("db.backend.error.clickhouse_validation_failed_manual", map[string]any{
"protocol": strings.ToUpper(protocolMode),
"host": config.Host,
"port": config.Port,
"detail": detail,
})
}
return fmt.Sprintf("ClickHouse 连接验证失败自动协议探测未成功Native 常见端口 9000/9440HTTP 常见端口 %s非标端口建议在连接协议中手动指定。%s",
clickHouseHTTPPortHint, detail)
return localizedDriverRuntimeText("db.backend.error.clickhouse_validation_failed_auto", map[string]any{
"httpPorts": clickHouseHTTPPortHint,
"detail": detail,
})
}
func withClickHouseProtocol(config connection.ConnectionConfig, protocol clickhouse.Protocol) connection.ConnectionConfig {
@@ -568,7 +590,7 @@ func clickHouseProtocolsForAttempt(config connection.ConnectionConfig) []clickho
func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
if strings.TrimSpace(reason) == "" {
reason = "ClickHouse 纯 Go 驱动未启用,请先在驱动管理中安装启用"
reason = localizedDriverRuntimeText("driver_manager.backend.status.optional_disabled", map[string]any{"name": "ClickHouse"})
}
return fmt.Errorf("%s", reason)
}
@@ -636,7 +658,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
idx+1, len(attempts), clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.UseSSL, stripHTTPClientProtocolVersion)
opts, err := c.buildClickHouseOptionsWithHTTPCompatibility(protocolConfig, stripHTTPClientProtocolVersion)
if err != nil {
failures = append(failures, fmt.Sprintf("第%d次 TLS 配置失败(protocol=%s): %v", idx+1, protocol.String(), err))
failures = append(failures, clickHouseTLSConfigFailedMessage(idx+1, protocol.String(), err))
logger.Warnf("ClickHouse TLS 配置失败:第%d组/%d 协议=%s 地址=%s:%d SSL=%t 原因=%v",
idx+1, len(attempts), clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.UseSSL, err)
lastProtocolErr = err
@@ -646,7 +668,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
if err := c.Ping(); err != nil {
lastProtocolErr = err
failureMessage := clickHouseAttemptFailureMessage(protocol, err)
failures = append(failures, fmt.Sprintf("第%d次连接验证失败(protocol=%s): %s", idx+1, protocol.String(), failureMessage))
failures = append(failures, clickHouseAttemptValidationFailedMessage(idx+1, protocol.String(), failureMessage))
logger.Warnf("ClickHouse 连接尝试失败:第%d组/%d 协议=%s 地址=%s:%d SSL=%t HTTP兼容=%t 原因=%s",
idx+1, len(attempts), clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.UseSSL, stripHTTPClientProtocolVersion, failureMessage)
if c.conn != nil {
@@ -911,7 +933,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
row := data[0]
if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok {
@@ -934,7 +956,7 @@ func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, err
if longest != "" {
return longest, nil
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -1093,7 +1115,7 @@ func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.Trigg
func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) {
rawTable := strings.TrimSpace(tableName)
if rawTable == "" {
return "", "", fmt.Errorf("表名不能为空")
return "", "", localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
resolvedDB := strings.TrimSpace(dbName)
@@ -1114,7 +1136,7 @@ func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string
resolvedDB = defaultClickHouseDatabase
}
if resolvedTable == "" {
return "", "", fmt.Errorf("表名不能为空")
return "", "", localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
return resolvedDB, resolvedTable, nil
}
@@ -1209,7 +1231,10 @@ func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeS
}
query := fmt.Sprintf("ALTER TABLE %s DELETE WHERE %s", qualifiedTable, whereExpr)
if _, err := c.conn.Exec(query); err != nil {
return fmt.Errorf("delete error: %v; sql=%s", err, query)
return localizedDatabaseRuntimeError("db.backend.error.clickhouse_delete_failed_with_sql", map[string]any{
"detail": err.Error(),
"sql": query,
})
}
}
@@ -1221,7 +1246,10 @@ func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeS
}
query := fmt.Sprintf("ALTER TABLE %s UPDATE %s WHERE %s", qualifiedTable, setExpr, whereExpr)
if _, err := c.conn.Exec(query); err != nil {
return fmt.Errorf("update error: %v; sql=%s", err, query)
return localizedDatabaseRuntimeError("db.backend.error.clickhouse_update_failed_with_sql", map[string]any{
"detail": err.Error(),
"sql": query,
})
}
}

View File

@@ -9,31 +9,52 @@ import (
"errors"
"io"
"net/http"
"os"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)
const fakeClickHouseDriverName = "gonavi-fake-clickhouse"
var clickHouseProtocolFailureI18nKeys = []string{
"db.backend.error.clickhouse_http_client_protocol_version_unsupported",
"db.backend.error.clickhouse_native_protocol_mismatch",
"db.backend.error.clickhouse_http_protocol_mismatch",
"db.backend.error.clickhouse_unknown_error",
"db.backend.error.clickhouse_driver_detail_missing",
"db.backend.error.clickhouse_attempt_tls_config_failed",
"db.backend.error.clickhouse_attempt_validation_failed",
"db.backend.error.clickhouse_validation_failed_manual",
"db.backend.error.clickhouse_validation_failed_auto",
}
const rawClickHouseCreateStatementNotFoundText = "未找到建表语句"
var (
registerFakeClickHouseDriverOnce sync.Once
fakeClickHouseStateMu sync.Mutex
fakeClickHouseState = struct {
pingErr error
queryErr error
execErr error
queryResults map[string]fakeClickHouseQueryResult
lastQuery string
queries []string
lastExec string
execQueries []string
}{
lastQuery: "",
queryResults: map[string]fakeClickHouseQueryResult{},
queries: nil,
lastExec: "",
execQueries: nil,
}
)
@@ -134,6 +155,229 @@ func TestClickHouseGetDatabasesFallsBackToCurrentDatabase(t *testing.T) {
}
}
func TestClickHouseCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tests := []struct {
name string
result fakeClickHouseQueryResult
}{
{
name: "empty rows",
result: fakeClickHouseQueryResult{
columns: []string{"statement"},
rows: nil,
},
},
{
name: "row without CREATE statement",
result: fakeClickHouseQueryResult{
columns: []string{"note"},
rows: [][]driver.Value{
{"SELECT 1"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registerFakeClickHouseDriverOnce.Do(func() {
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
})
conn, err := sql.Open(fakeClickHouseDriverName, "")
if err != nil {
t.Fatalf("open fake clickhouse db failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
const showCreateSQL = "SHOW CREATE TABLE `app`.`orders`"
fakeClickHouseStateMu.Lock()
fakeClickHouseState.pingErr = nil
fakeClickHouseState.queryErr = nil
fakeClickHouseState.queryResults = map[string]fakeClickHouseQueryResult{
showCreateSQL: tt.result,
}
fakeClickHouseState.lastQuery = ""
fakeClickHouseState.queries = nil
fakeClickHouseStateMu.Unlock()
clickhouseDB := &ClickHouseDB{conn: conn}
_, err = clickhouseDB.GetCreateStatement("app", "orders")
if err == nil {
t.Fatal("expected ClickHouse GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawClickHouseCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
})
}
}
func TestClickHouseCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawClickHouseCreateStatementNotFoundText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw create-statement text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("clickhouse_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
}
func TestClickHouseApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
registerFakeClickHouseDriverOnce.Do(func() {
sql.Register(fakeClickHouseDriverName, fakeClickHouseDriver{})
})
tests := []struct {
name string
changes connection.ChangeSet
wantText string
forbiddenRaw []string
}{
{
name: "delete failure",
changes: connection.ChangeSet{
Deletes: []map[string]interface{}{
{"id": int64(42)},
},
},
wantText: "Failed to delete ClickHouse rows",
forbiddenRaw: []string{"delete error", "删除失败"},
},
{
name: "update failure",
changes: connection.ChangeSet{
Updates: []connection.UpdateRow{
{
Keys: map[string]interface{}{"id": int64(42)},
Values: map[string]interface{}{"name": "Alice"},
},
},
},
wantText: "Failed to update ClickHouse rows",
forbiddenRaw: []string{"update error", "更新失败"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conn, err := sql.Open(fakeClickHouseDriverName, "")
if err != nil {
t.Fatalf("open fake clickhouse db failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
fakeClickHouseStateMu.Lock()
fakeClickHouseState.execErr = errors.New("driver raw failure")
fakeClickHouseState.lastExec = ""
fakeClickHouseState.execQueries = nil
fakeClickHouseStateMu.Unlock()
clickhouseDB := &ClickHouseDB{conn: conn, database: "analytics"}
err = clickhouseDB.ApplyChanges("orders", tt.changes)
if err == nil {
t.Fatal("expected ApplyChanges to fail")
}
got := err.Error()
if !strings.Contains(got, tt.wantText) {
t.Fatalf("expected localized wrapper %q, got %q", tt.wantText, got)
}
if !strings.Contains(got, "driver raw failure") {
t.Fatalf("expected raw driver detail to remain, got %q", got)
}
if !strings.Contains(got, "ALTER TABLE `analytics`.`orders`") {
t.Fatalf("expected raw SQL to remain, got %q", got)
}
for _, raw := range tt.forbiddenRaw {
if strings.Contains(got, raw) {
t.Fatalf("expected no raw wrapper %q, got %q", raw, got)
}
}
})
}
}
func TestClickHouseTableNameRequiredUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
clickhouseDB := &ClickHouseDB{}
_, _, err := clickhouseDB.resolveDatabaseAndTable("", " ")
if err == nil {
t.Fatal("expected table-name-required error")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawClickHouseTableNameRequiredText()) {
t.Fatalf("expected no raw Chinese table-name-required text, got %q", err.Error())
}
}
func TestClickHouseApplyChangesErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
`fmt.Errorf("` + rawClickHouseTableNameRequiredText() + `")`,
`fmt.Errorf("delete error: %v; sql=%s", err, query)`,
`fmt.Errorf("update error: %v; sql=%s", err, query)`,
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw ApplyChanges text %q", rawMessage)
}
}
for _, key := range clickHouseApplyChangesI18nKeys() {
if !strings.Contains(source, key) {
t.Fatalf("clickhouse_impl.go does not reference i18n key %q", key)
}
}
}
func TestClickHouseApplyChangesCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range clickHouseApplyChangesI18nKeys() {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing ClickHouse ApplyChanges key %q", language, key)
}
}
}
}
func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
tests := []struct {
name string
@@ -323,6 +567,131 @@ func TestClickHouseHTTPClientProtocolVersionUnsupportedEnablesCompatibilityRetry
}
}
func TestClickHouseProtocolFailureMessagesUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
clientProtocolErr := errors.New(`Code: 115. DB::Exception: Unknown setting client_protocol_version. (UNKNOWN_SETTING)`)
compatMessage := clickHouseAttemptFailureMessage(clickhouse.HTTP, clientProtocolErr)
if !strings.Contains(compatMessage, "client_protocol_version") || !strings.Contains(compatMessage, "HTTP compatibility mode") {
t.Fatalf("expected English compatibility hint, got %q", compatMessage)
}
if strings.Contains(compatMessage, "兼容模式") || strings.Contains(compatMessage, "当前") {
t.Fatalf("expected no Chinese compatibility hint, got %q", compatMessage)
}
nativeMismatch := clickHouseAttemptFailureMessage(clickhouse.Native, errors.New("code: 27, message: Cannot parse input: expected '(' before: '\x02\x00\x01\x00'"))
if !strings.Contains(nativeMismatch, "does not look like a Native handshake") {
t.Fatalf("expected English native mismatch hint, got %q", nativeMismatch)
}
if strings.Contains(nativeMismatch, "不像 Native") {
t.Fatalf("expected no Chinese native mismatch hint, got %q", nativeMismatch)
}
httpMismatch := clickHouseAttemptFailureMessage(clickhouse.HTTP, errors.New("malformed HTTP response"))
if !strings.Contains(httpMismatch, "does not look like an HTTP response") {
t.Fatalf("expected English HTTP mismatch hint, got %q", httpMismatch)
}
if strings.Contains(httpMismatch, "不像 HTTP") {
t.Fatalf("expected no Chinese HTTP mismatch hint, got %q", httpMismatch)
}
unknownMessage := clickHouseAttemptFailureMessage(clickhouse.HTTP, nil)
if unknownMessage != "Unknown error" {
t.Fatalf("expected localized unknown error, got %q", unknownMessage)
}
}
func TestClickHouseConnectFailureSummaryUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
manual := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 9000,
ClickHouseProtocol: clickHouseProtocolNative,
}, []string{"driver raw detail"})
if !strings.Contains(manual, "ClickHouse connection validation failed") ||
!strings.Contains(manual, "used user-selected NATIVE protocol") ||
!strings.Contains(manual, "driver raw detail") {
t.Fatalf("expected English manual protocol failure summary with raw detail, got %q", manual)
}
if strings.Contains(manual, "连接验证失败") || strings.Contains(manual, "用户选择") || strings.Contains(manual, "第1次") {
t.Fatalf("expected no Chinese manual summary, got %q", manual)
}
manualWithMultipleDetails := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 9000,
ClickHouseProtocol: clickHouseProtocolNative,
}, []string{"first raw detail", "second raw detail"})
if !strings.Contains(manualWithMultipleDetails, "first raw detail; second raw detail") {
t.Fatalf("expected ASCII separator between raw details, got %q", manualWithMultipleDetails)
}
if strings.Contains(manualWithMultipleDetails, "") {
t.Fatalf("expected no Chinese separator between raw details, got %q", manualWithMultipleDetails)
}
auto := clickHouseConnectFailureSummary(connection.ConnectionConfig{
Host: "clickhouse.local",
Port: 8123,
}, nil)
if !strings.Contains(auto, "Automatic protocol detection failed") ||
!strings.Contains(auto, "No driver error details were returned") {
t.Fatalf("expected English auto protocol failure summary, got %q", auto)
}
if strings.Contains(auto, "自动协议探测") || strings.Contains(auto, "未获取到") {
t.Fatalf("expected no Chinese auto summary, got %q", auto)
}
}
func TestClickHouseProtocolFailureSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("clickhouse_impl.go")
if err != nil {
t.Fatalf("read clickhouse_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
"当前 ClickHouse HTTP 端口不支持 client_protocol_version",
"服务端响应不像 Native 握手",
"服务端响应不像 HTTP 响应",
"未知错误",
"未获取到驱动返回的错误详情",
"ClickHouse 连接验证失败",
"第%d次 TLS 配置失败",
"第%d次连接验证失败",
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("clickhouse_impl.go still contains raw user-facing ClickHouse protocol text %q", rawMessage)
}
}
for _, key := range clickHouseProtocolFailureI18nKeys {
if !strings.Contains(source, key) {
t.Fatalf("clickhouse_impl.go does not reference i18n key %q", key)
}
}
}
func TestClickHouseProtocolFailureCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range clickHouseProtocolFailureI18nKeys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing ClickHouse protocol failure key %q", language, key)
}
}
}
}
func TestClickHouseHTTPClientProtocolVersionStripperRemovesDriverQueryParam(t *testing.T) {
var seenQuery string
stripper := clickHouseHTTPClientProtocolVersionStripper{
@@ -446,6 +815,18 @@ func protocolNames(protocols []clickhouse.Protocol) []string {
return names
}
func rawClickHouseTableNameRequiredText() string {
return string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
}
func clickHouseApplyChangesI18nKeys() []string {
return []string{
"db.backend.error.table_name_required",
"db.backend.error.clickhouse_delete_failed_with_sql",
"db.backend.error.clickhouse_update_failed_with_sql",
}
}
type fakeClickHouseDriver struct{}
func (fakeClickHouseDriver) Open(name string) (driver.Conn, error) {
@@ -489,6 +870,17 @@ func (fakeClickHouseConn) QueryContext(ctx context.Context, query string, args [
return &fakeClickHouseRows{}, nil
}
func (fakeClickHouseConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
fakeClickHouseStateMu.Lock()
defer fakeClickHouseStateMu.Unlock()
fakeClickHouseState.lastExec = query
fakeClickHouseState.execQueries = append(fakeClickHouseState.execQueries, query)
if fakeClickHouseState.execErr != nil {
return nil, fakeClickHouseState.execErr
}
return driver.RowsAffected(1), nil
}
type fakeClickHouseRows struct {
columns []string
rows [][]driver.Value

View File

@@ -38,7 +38,7 @@ func (c *CustomDB) Connect(config connection.ConnectionConfig) error {
c.driver = driver
c.pingTimeout = getConnectTimeout(config)
if err := c.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
return wrapDatabaseConnectionVerifyError(err)
}
return nil
}
@@ -49,11 +49,15 @@ func formatCustomDriverOpenError(driver string, err error) error {
}
if strings.Contains(strings.ToLower(err.Error()), "unknown driver") {
if isLikelySystemODBCDriverName(driver) {
return fmt.Errorf("打开数据库连接失败:自定义连接不支持直接填写系统 ODBC/JDBC 驱动名 %q请填写 GoNavi 已注册的 Go database/sql 驱动名。当前版本未注册通用 ODBC 驱动,因此暂不支持通过 %q 连接 InterSystems IRIS%w", driver, driver, err)
return fmt.Errorf("%s%w", localizedDriverRuntimeText("db.backend.error.custom_driver_system_odbc_unsupported_prefix", map[string]any{
"driver": driver,
}), err)
}
return fmt.Errorf("打开数据库连接失败:自定义连接驱动 %q 未在 GoNavi 中注册;请填写已注册的 Go database/sql 驱动名,不能填写系统 ODBC/JDBC 驱动名:%w", driver, err)
return fmt.Errorf("%s%w", localizedDriverRuntimeText("db.backend.error.custom_driver_unregistered_prefix", map[string]any{
"driver": driver,
}), err)
}
return fmt.Errorf("打开数据库连接失败:%w", err)
return wrapDatabaseConnectionOpenError(err)
}
func isLikelySystemODBCDriverName(driver string) bool {
@@ -73,7 +77,7 @@ func (c *CustomDB) Close() error {
func (c *CustomDB) Ping() error {
if c.conn == nil {
return fmt.Errorf("连接未打开")
return localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
timeout := c.pingTimeout
if timeout <= 0 {
@@ -86,7 +90,7 @@ func (c *CustomDB) Ping() error {
func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
rows, err := c.conn.QueryContext(ctx, query)
@@ -100,7 +104,7 @@ func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string
func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error) {
if c.conn == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
rows, err := c.conn.Query(query)
@@ -120,7 +124,7 @@ func (c *CustomDB) scanDialect() string {
func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
res, err := c.conn.ExecContext(ctx, query)
if err != nil {
@@ -131,7 +135,7 @@ func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error)
func (c *CustomDB) Exec(query string) (int64, error) {
if c.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
res, err := c.conn.Exec(query)
if err != nil {
@@ -384,7 +388,7 @@ func (c *CustomDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if c.conn == nil {
return fmt.Errorf("连接未打开")
return localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
tx, err := c.conn.Begin()
@@ -464,7 +468,7 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("删除失败:%v", err)
return localizedDatabaseRuntimeError("db.backend.error.row_delete_failed", map[string]any{"detail": err.Error()})
}
}
@@ -492,12 +496,12 @@ func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet)
}
if len(wheres) == 0 {
return fmt.Errorf("更新操作需要主键条件")
return localizedDatabaseRuntimeError("db.backend.error.row_update_key_conditions_required", nil)
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("更新失败:%v", err)
return localizedDatabaseRuntimeError("db.backend.error.row_update_failed", map[string]any{"detail": err.Error()})
}
}

View File

@@ -1,18 +1,34 @@
package db
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
const customMySQLDSNRecordingDriverName = "custom-mysql-dsn-recording"
var customMySQLDSNRecordingLastDSN string
const customApplyChangesI18nDriverName = "custom-applychanges-i18n"
var (
registerCustomApplyChangesI18nDriverOnce sync.Once
customApplyChangesI18nStateMu sync.Mutex
customApplyChangesI18nState = struct {
failPrefix string
err error
}{}
)
type customMySQLDSNRecordingDriver struct{}
func (d customMySQLDSNRecordingDriver) Open(name string) (driver.Conn, error) {
@@ -34,10 +50,316 @@ func (c customMySQLDSNRecordingConn) Begin() (driver.Tx, error) {
return nil, driver.ErrSkip
}
type customApplyChangesI18nDriver struct{}
type customApplyChangesI18nConn struct{}
type customApplyChangesI18nTx struct{}
func (d customApplyChangesI18nDriver) Open(name string) (driver.Conn, error) {
return customApplyChangesI18nConn{}, nil
}
func (c customApplyChangesI18nConn) Prepare(query string) (driver.Stmt, error) {
return nil, errors.New("prepare not implemented")
}
func (c customApplyChangesI18nConn) Close() error {
return nil
}
func (c customApplyChangesI18nConn) Begin() (driver.Tx, error) {
return customApplyChangesI18nTx{}, nil
}
func (c customApplyChangesI18nConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
return customApplyChangesI18nTx{}, nil
}
func (c customApplyChangesI18nConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
customApplyChangesI18nStateMu.Lock()
defer customApplyChangesI18nStateMu.Unlock()
normalizedQuery := strings.ToUpper(strings.TrimSpace(query))
if customApplyChangesI18nState.err != nil && strings.HasPrefix(normalizedQuery, customApplyChangesI18nState.failPrefix) {
return nil, customApplyChangesI18nState.err
}
return driver.RowsAffected(1), nil
}
func (tx customApplyChangesI18nTx) Commit() error {
return nil
}
func (tx customApplyChangesI18nTx) Rollback() error {
return nil
}
func init() {
sql.Register(customMySQLDSNRecordingDriverName, customMySQLDSNRecordingDriver{})
}
func openCustomApplyChangesI18nDB(t *testing.T, failPrefix string, err error) *sql.DB {
t.Helper()
registerCustomApplyChangesI18nDriverOnce.Do(func() {
sql.Register(customApplyChangesI18nDriverName, customApplyChangesI18nDriver{})
})
customApplyChangesI18nStateMu.Lock()
customApplyChangesI18nState.failPrefix = failPrefix
customApplyChangesI18nState.err = err
customApplyChangesI18nStateMu.Unlock()
db, openErr := sql.Open(customApplyChangesI18nDriverName, "")
if openErr != nil {
t.Fatalf("open custom ApplyChanges i18n test DB failed: %v", openErr)
}
t.Cleanup(func() {
_ = db.Close()
customApplyChangesI18nStateMu.Lock()
customApplyChangesI18nState.failPrefix = ""
customApplyChangesI18nState.err = nil
customApplyChangesI18nStateMu.Unlock()
})
return db
}
func TestCustomDBApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
rawConnectionNotOpenText := string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
rawDeleteFailedText := string([]rune{0x5220, 0x9664, 0x5931, 0x8d25})
rawUpdateKeyConditionsRequiredText := string([]rune{0x66f4, 0x65b0, 0x64cd, 0x4f5c, 0x9700, 0x8981, 0x4e3b, 0x952e, 0x6761, 0x4ef6})
rawUpdateFailedText := string([]rune{0x66f4, 0x65b0, 0x5931, 0x8d25})
t.Run("connection not open", func(t *testing.T) {
err := (&CustomDB{}).ApplyChanges("orders", connection.ChangeSet{})
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawConnectionNotOpenText) {
t.Fatalf("expected no raw connection-not-open text, got %q", err.Error())
}
})
t.Run("delete failure", func(t *testing.T) {
rawErr := errors.New("driver raw delete failure")
customDB := &CustomDB{conn: openCustomApplyChangesI18nDB(t, "DELETE", rawErr), driver: "mysql"}
err := customDB.ApplyChanges("orders", connection.ChangeSet{
Deletes: []map[string]interface{}{
{"id": int64(42)},
},
})
if err == nil {
t.Fatal("expected delete failure")
}
if err.Error() != "Delete failed: driver raw delete failure" {
t.Fatalf("expected English delete failure, got %q", err.Error())
}
if strings.Contains(err.Error(), rawDeleteFailedText) {
t.Fatalf("expected no raw delete wrapper, got %q", err.Error())
}
})
t.Run("update key condition required", func(t *testing.T) {
customDB := &CustomDB{conn: openCustomApplyChangesI18nDB(t, "", nil), driver: "mysql"}
err := customDB.ApplyChanges("orders", connection.ChangeSet{
Updates: []connection.UpdateRow{{
Values: map[string]interface{}{
"name": "Alice",
},
}},
})
if err == nil {
t.Fatal("expected update-key-condition error")
}
if err.Error() != "Update operation requires key conditions" {
t.Fatalf("expected English update-key-condition error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawUpdateKeyConditionsRequiredText) {
t.Fatalf("expected no raw update-key-condition text, got %q", err.Error())
}
})
t.Run("update failure", func(t *testing.T) {
rawErr := errors.New("driver raw update failure")
customDB := &CustomDB{conn: openCustomApplyChangesI18nDB(t, "UPDATE", rawErr), driver: "mysql"}
err := customDB.ApplyChanges("orders", connection.ChangeSet{
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{
"id": int64(42),
},
Values: map[string]interface{}{
"name": "Alice",
},
}},
})
if err == nil {
t.Fatal("expected update failure")
}
if err.Error() != "Update failed: driver raw update failure" {
t.Fatalf("expected English update failure, got %q", err.Error())
}
if strings.Contains(err.Error(), rawUpdateFailedText) {
t.Fatalf("expected no raw update wrapper, got %q", err.Error())
}
})
}
func TestCustomDBApplyChangesErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("custom_impl.go")
if err != nil {
t.Fatalf("read custom_impl.go: %v", err)
}
source := string(sourceBytes)
functionSource := databaseFunctionSource(t, source, "func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error")
rawConnectionNotOpenText := string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
rawDeleteFailedText := string([]rune{0x5220, 0x9664, 0x5931, 0x8d25})
rawUpdateKeyConditionsRequiredText := string([]rune{0x66f4, 0x65b0, 0x64cd, 0x4f5c, 0x9700, 0x8981, 0x4e3b, 0x952e, 0x6761, 0x4ef6})
rawUpdateFailedText := string([]rune{0x66f4, 0x65b0, 0x5931, 0x8d25})
for _, rawMessage := range []string{
`fmt.Errorf("` + rawConnectionNotOpenText + `")`,
`fmt.Errorf("` + rawDeleteFailedText + `%v", err)`,
`fmt.Errorf("` + rawUpdateKeyConditionsRequiredText + `")`,
`fmt.Errorf("` + rawUpdateFailedText + `%v", err)`,
} {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("CustomDB ApplyChanges still contains raw user-visible text %q", rawMessage)
}
}
for _, key := range customDBApplyChangesI18nKeys() {
if !strings.Contains(functionSource, key) {
t.Fatalf("CustomDB ApplyChanges does not reference i18n key %q", key)
}
}
}
func TestCustomDBApplyChangesCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range customDBApplyChangesI18nKeys() {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing CustomDB ApplyChanges key %q", language, key)
}
}
}
}
func customDBApplyChangesI18nKeys() []string {
return []string{
"db.backend.error.connection_not_open",
"db.backend.error.row_delete_failed",
"db.backend.error.row_update_key_conditions_required",
"db.backend.error.row_update_failed",
}
}
func TestCustomDBBasicExecutionConnectionNotOpenUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
rawConnectionNotOpenText := string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
customDB := &CustomDB{}
cases := []struct {
name string
run func() error
}{
{
name: "Ping",
run: customDB.Ping,
},
{
name: "Query",
run: func() error {
_, _, err := customDB.Query("SELECT 1")
return err
},
},
{
name: "QueryContext",
run: func() error {
_, _, err := customDB.QueryContext(context.Background(), "SELECT 1")
return err
},
},
{
name: "Exec",
run: func() error {
_, err := customDB.Exec("UPDATE demo SET name = 'raw'")
return err
},
},
{
name: "ExecContext",
run: func() error {
_, err := customDB.ExecContext(context.Background(), "UPDATE demo SET name = 'raw'")
return err
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.run()
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawConnectionNotOpenText) {
t.Fatalf("expected no raw connection-not-open text, got %q", err.Error())
}
})
}
}
func TestCustomDBBasicExecutionConnectionNotOpenSourcesUseI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("custom_impl.go")
if err != nil {
t.Fatalf("read custom_impl.go: %v", err)
}
source := string(sourceBytes)
rawConnectionNotOpenText := string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
for _, signature := range []string{
"func (c *CustomDB) Ping() error",
"func (c *CustomDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error)",
"func (c *CustomDB) Query(query string) ([]map[string]interface{}, []string, error)",
"func (c *CustomDB) ExecContext(ctx context.Context, query string) (int64, error)",
"func (c *CustomDB) Exec(query string) (int64, error)",
} {
functionSource := databaseFunctionSource(t, source, signature)
if strings.Contains(functionSource, `fmt.Errorf("`+rawConnectionNotOpenText+`")`) {
t.Fatalf("%s still contains raw connection-not-open text", signature)
}
if !strings.Contains(functionSource, "db.backend.error.connection_not_open") {
t.Fatalf("%s does not reference connection-not-open i18n key", signature)
}
}
}
func TestCustomDBConnectReportsUnsupportedODBCDriverName(t *testing.T) {
db := &CustomDB{}

View File

@@ -0,0 +1,116 @@
//go:build gonavi_full_drivers || gonavi_dameng_driver
package db
import (
"database/sql"
"database/sql/driver"
"io"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/shared/i18n"
)
type damengI18nEmptyRowsDriver struct{}
type damengI18nEmptyRowsConn struct{}
type damengI18nEmptyRowsStmt struct{}
type damengI18nEmptyRowsRows struct{}
var registerDamengI18nEmptyRowsDriverOnce sync.Once
var rawDamengCreateStatementNotFoundText = string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5})
func (damengI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return damengI18nEmptyRowsConn{}, nil
}
func (damengI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return damengI18nEmptyRowsStmt{}, nil
}
func (damengI18nEmptyRowsConn) Close() error { return nil }
func (damengI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_not_open", nil)
}
func (damengI18nEmptyRowsStmt) Close() error { return nil }
func (damengI18nEmptyRowsStmt) NumInput() int { return -1 }
func (damengI18nEmptyRowsStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.RowsAffected(0), nil
}
func (damengI18nEmptyRowsStmt) Query(args []driver.Value) (driver.Rows, error) {
return damengI18nEmptyRowsRows{}, nil
}
func (damengI18nEmptyRowsRows) Columns() []string {
return []string{"DDL"}
}
func (damengI18nEmptyRowsRows) Close() error { return nil }
func (damengI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openDamengI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerDamengI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("dameng_i18n_empty_rows", damengI18nEmptyRowsDriver{})
})
conn, err := sql.Open("dameng_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open dameng_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func TestDamengCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
damengDB := &DamengDB{conn: openDamengI18nEmptyRowsDB(t)}
_, err := damengDB.GetCreateStatement("app", "orders")
if err == nil {
t.Fatal("expected Dameng GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawDamengCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
}
func TestDamengCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("dameng_impl.go")
if err != nil {
t.Fatalf("read dameng_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawDamengCreateStatementNotFoundText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("dameng_impl.go still contains raw create-statement text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("dameng_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
}

View File

@@ -263,7 +263,7 @@ func (d *DamengDB) GetCreateStatement(dbName, tableName string) (string, error)
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (d *DamengDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {

View File

@@ -147,9 +147,27 @@ func NewSQLConnStatementExecerWithDialect(conn *sql.Conn, scanDialect string) St
return &sqlConnStatementExecer{conn: conn, scanDialect: scanDialect}
}
func localizedDatabaseRuntimeError(key string, params map[string]any) error {
return fmt.Errorf("%s", localizedDriverRuntimeText(key, params))
}
func wrapDatabaseConnectionOpenError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s%w", localizedDriverRuntimeText("db.backend.error.connection_open_failed_prefix", nil), err)
}
func wrapDatabaseConnectionVerifyError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s%w", localizedDriverRuntimeText("db.backend.error.connection_verify_failed_prefix", nil), err)
}
func (e *sqlConnStatementExecer) ExecContext(ctx context.Context, query string) (int64, error) {
if e == nil || e.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
res, err := e.conn.ExecContext(ctx, query)
if err != nil {
@@ -164,7 +182,7 @@ func (e *sqlConnStatementExecer) Exec(query string) (int64, error) {
func (e *sqlConnStatementExecer) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if e == nil || e.conn == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
rows, err := e.conn.QueryContext(ctx, query)
if err != nil {
@@ -180,7 +198,7 @@ func (e *sqlConnStatementExecer) Query(query string) ([]map[string]interface{},
func (e *sqlConnStatementExecer) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error) {
if e == nil || e.conn == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
rows, err := e.conn.QueryContext(ctx, query)
if err != nil {
@@ -229,15 +247,15 @@ func NewSQLConnTransactionExecerWithDialect(conn *sql.Conn, commitSQL string, ro
func (e *sqlConnTransactionExecer) activeConn() (*sql.Conn, error) {
if e == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
e.mu.Lock()
defer e.mu.Unlock()
if e.conn == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
if e.done {
return nil, fmt.Errorf("事务已结束")
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_already_finished", nil)
}
return e.conn, nil
}
@@ -358,12 +376,12 @@ func NewSQLTxStatementExecer(tx *sql.Tx) TransactionExecer {
func (e *sqlTxStatementExecer) activeTx() (*sql.Tx, error) {
if e == nil || e.tx == nil {
return nil, fmt.Errorf("事务未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_not_open", nil)
}
e.mu.Lock()
defer e.mu.Unlock()
if e.done {
return nil, fmt.Errorf("事务已结束")
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_already_finished", nil)
}
return e.tx, nil
}
@@ -462,16 +480,43 @@ type ChangePreviewer interface {
PreviewChanges(tableName string, changes connection.ChangeSet) (deletes, updates, inserts []string)
}
func requireSingleRowAffected(result sql.Result, action string) error {
type rowMutationAction string
const (
rowMutationActionDelete rowMutationAction = "delete"
rowMutationActionUpdate rowMutationAction = "update"
)
func localizedRowMutationAction(action rowMutationAction) string {
switch action {
case rowMutationActionDelete:
return localizedDriverRuntimeText("db.backend.action.delete", nil)
case rowMutationActionUpdate:
return localizedDriverRuntimeText("db.backend.action.update", nil)
default:
return strings.TrimSpace(string(action))
}
}
func requireSingleRowAffected(result sql.Result, action rowMutationAction) error {
actionLabel := localizedRowMutationAction(action)
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("%s未生效:无法确认影响行数:%v", action, err)
return fmt.Errorf("%s", localizedDriverRuntimeText("db.backend.error.row_action_not_effective_rows_affected_unknown", map[string]any{
"action": actionLabel,
"detail": err.Error(),
}))
}
if affected == 0 {
return fmt.Errorf("%s未生效:未匹配到任何行", action)
return fmt.Errorf("%s", localizedDriverRuntimeText("db.backend.error.row_action_not_effective_no_rows_matched", map[string]any{
"action": actionLabel,
}))
}
if affected != 1 {
return fmt.Errorf("%s未生效:影响了 %d 行,期望只影响 1 行", action, affected)
return fmt.Errorf("%s", localizedDriverRuntimeText("db.backend.error.row_action_not_effective_multiple_rows", map[string]any{
"action": actionLabel,
"count": affected,
}))
}
return nil
}
@@ -575,7 +620,7 @@ func NewDatabase(dbType string) (Database, error) {
}
factory, ok := databaseFactories[normalized]
if !ok {
return nil, fmt.Errorf("不支持的数据库类型:%s", dbType)
return nil, localizedDatabaseRuntimeError("db.backend.error.unsupported_database_type", map[string]any{"dbType": dbType})
}
return factory(), nil
}

View File

@@ -0,0 +1,677 @@
package db
import (
"context"
"database/sql"
"errors"
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
type fakeRowsAffectedResult struct {
affected int64
err error
}
const (
rawTransactionAlreadyFinishedText = "\u4e8b\u52a1\u5df2\u7ed3\u675f"
rawTransactionNotOpenText = "\u4e8b\u52a1\u672a\u6253\u5f00"
)
func (r fakeRowsAffectedResult) LastInsertId() (int64, error) {
return 0, nil
}
func (r fakeRowsAffectedResult) RowsAffected() (int64, error) {
if r.err != nil {
return 0, r.err
}
return r.affected, nil
}
func databaseFunctionSource(t *testing.T, source string, signature string) string {
t.Helper()
start := strings.Index(source, signature)
if start < 0 {
t.Fatalf("database.go missing function signature %q", signature)
}
rest := source[start+len(signature):]
end := strings.Index(rest, "\nfunc ")
if end < 0 {
return source[start:]
}
return source[start : start+len(signature)+end]
}
func TestRequireSingleRowAffectedUsesLocalizedText(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
cases := []struct {
name string
result fakeRowsAffectedResult
action rowMutationAction
want string
}{
{
name: "delete rows affected unavailable",
result: fakeRowsAffectedResult{err: errors.New("rows affected unsupported")},
action: rowMutationActionDelete,
want: "Delete did not take effect: could not determine affected rows: rows affected unsupported",
},
{
name: "delete no rows matched",
result: fakeRowsAffectedResult{affected: 0},
action: rowMutationActionDelete,
want: "Delete did not take effect: no rows matched",
},
{
name: "update multiple rows affected",
result: fakeRowsAffectedResult{affected: 2},
action: rowMutationActionUpdate,
want: "Update did not take effect: affected 2 rows; expected exactly 1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := requireSingleRowAffected(tc.result, tc.action)
if err == nil {
t.Fatal("expected row affected validation error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
})
}
}
func TestRequireSingleRowAffectedSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("database.go")
if err != nil {
t.Fatalf("read database.go: %v", err)
}
source := string(sourceBytes)
functionSource := databaseFunctionSource(t, source, "func requireSingleRowAffected(result sql.Result, action rowMutationAction) error")
actionSource := databaseFunctionSource(t, source, "func localizedRowMutationAction(action rowMutationAction) string")
for _, rawMessage := range []string{
`fmt.Errorf("%s未生效无法确认影响行数%v", action, err)`,
`fmt.Errorf("%s未生效未匹配到任何行", action)`,
`fmt.Errorf("%s未生效影响了 %d 行,期望只影响 1 行", action, affected)`,
} {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("requireSingleRowAffected still contains raw row affected text %q", rawMessage)
}
}
for _, key := range []string{
"db.backend.error.row_action_not_effective_rows_affected_unknown",
"db.backend.error.row_action_not_effective_no_rows_matched",
"db.backend.error.row_action_not_effective_multiple_rows",
} {
if !strings.Contains(functionSource, key) {
t.Fatalf("requireSingleRowAffected does not reference i18n key %q", key)
}
}
for _, key := range []string{
"db.backend.action.delete",
"db.backend.action.update",
} {
if !strings.Contains(actionSource, key) {
t.Fatalf("localizedRowMutationAction does not reference i18n key %q", key)
}
}
}
func TestDatabaseRowAffectedCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.action.delete",
"db.backend.action.update",
"db.backend.error.row_action_not_effective_rows_affected_unknown",
"db.backend.error.row_action_not_effective_no_rows_matched",
"db.backend.error.row_action_not_effective_multiple_rows",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing database row-affected key %q", language, key)
}
}
}
}
func TestSQLConnStatementExecerUsesCurrentLanguageForConnectionNotOpen(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
execer := &sqlConnStatementExecer{}
cases := []struct {
name string
call func() error
}{
{
name: "exec",
call: func() error {
_, err := execer.ExecContext(context.Background(), "SELECT 1")
return err
},
},
{
name: "query",
call: func() error {
_, _, err := execer.QueryContext(context.Background(), "SELECT 1")
return err
},
},
{
name: "query_multi",
call: func() error {
_, err := execer.QueryMultiContext(context.Background(), "SELECT 1")
return err
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("expected no Chinese connection-not-open text, got %q", err.Error())
}
})
}
}
func TestSQLConnTransactionExecerUsesCurrentLanguageForConnectionNotOpen(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
execer := &sqlConnTransactionExecer{}
_, err := execer.activeConn()
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("expected no Chinese connection-not-open text, got %q", err.Error())
}
}
func TestDatabaseConnectionNotOpenSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("database.go")
if err != nil {
t.Fatalf("read database.go: %v", err)
}
source := string(sourceBytes)
checks := map[string]string{
"func (e *sqlConnStatementExecer) ExecContext(ctx context.Context, query string) (int64, error)": "db.backend.error.connection_not_open",
"func (e *sqlConnStatementExecer) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error)": "db.backend.error.connection_not_open",
"func (e *sqlConnStatementExecer) QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error)": "db.backend.error.connection_not_open",
"func (e *sqlConnTransactionExecer) activeConn() (*sql.Conn, error)": "db.backend.error.connection_not_open",
}
for signature, key := range checks {
functionSource := databaseFunctionSource(t, source, signature)
if strings.Contains(functionSource, `fmt.Errorf("连接未打开")`) {
t.Fatalf("%s still contains raw connection-not-open text", signature)
}
if !strings.Contains(functionSource, key) {
t.Fatalf("%s does not reference i18n key %q", signature, key)
}
}
}
func TestDatabaseConnectionNotOpenCatalogKeyExists(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
if strings.TrimSpace(catalog["db.backend.error.connection_not_open"]) == "" {
t.Fatalf("%s catalog missing database connection-not-open key", language)
}
}
}
func TestWrapDatabaseConnectionErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
baseErr := errors.New("driver unavailable")
cases := []struct {
name string
call func(error) error
want string
}{
{
name: "open",
call: wrapDatabaseConnectionOpenError,
want: "Failed to open database connection: driver unavailable",
},
{
name: "verify",
call: wrapDatabaseConnectionVerifyError,
want: "Failed to verify the established connection: driver unavailable",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call(baseErr)
if err == nil {
t.Fatal("expected wrapped database connection error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
if !errors.Is(err, baseErr) {
t.Fatal("expected wrapped error to preserve cause")
}
if strings.Contains(err.Error(), "打开数据库连接失败") || strings.Contains(err.Error(), "连接建立后验证失败") {
t.Fatalf("expected no raw Chinese connection wrapper text, got %q", err.Error())
}
})
}
}
func TestDatabaseConnectionWrapperHelperUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("database.go")
if err != nil {
t.Fatalf("read database.go: %v", err)
}
source := string(sourceBytes)
checks := map[string]string{
"func wrapDatabaseConnectionOpenError(err error) error": "db.backend.error.connection_open_failed_prefix",
"func wrapDatabaseConnectionVerifyError(err error) error": "db.backend.error.connection_verify_failed_prefix",
}
for signature, key := range checks {
functionSource := databaseFunctionSource(t, source, signature)
if strings.Contains(functionSource, `fmt.Errorf("打开数据库连接失败:%w", err)`) || strings.Contains(functionSource, `fmt.Errorf("连接建立后验证失败:%w", err)`) {
t.Fatalf("%s still contains raw database connection wrapper text", signature)
}
if !strings.Contains(functionSource, key) {
t.Fatalf("%s does not reference i18n key %q", signature, key)
}
}
}
func TestDatabaseConnectionWrapperSourcesUseI18nHelpers(t *testing.T) {
type sourceCheck struct {
path string
requiredTexts []string
}
checks := []sourceCheck{
{
path: "custom_impl.go",
requiredTexts: []string{
"wrapDatabaseConnectionOpenError(err)",
"wrapDatabaseConnectionVerifyError(err)",
},
},
{
path: "mariadb_impl.go",
requiredTexts: []string{
"wrapDatabaseConnectionOpenError(err)",
"wrapDatabaseConnectionVerifyError(err)",
},
},
{
path: "sqlite_impl.go",
requiredTexts: []string{
"wrapDatabaseConnectionOpenError(err)",
"wrapDatabaseConnectionVerifyError(err)",
},
},
{
path: "sqlserver_impl.go",
requiredTexts: []string{
"wrapDatabaseConnectionOpenError(err)",
"wrapDatabaseConnectionVerifyError(err)",
},
},
{
path: "iris_impl.go",
requiredTexts: []string{
"wrapDatabaseConnectionOpenError(err)",
"wrapDatabaseConnectionVerifyError(err)",
},
},
}
for _, check := range checks {
sourceBytes, err := os.ReadFile(check.path)
if err != nil {
t.Fatalf("read %s: %v", check.path, err)
}
source := string(sourceBytes)
if strings.Contains(source, `fmt.Errorf("打开数据库连接失败:%w", err)`) {
t.Fatalf("%s still contains raw open-connection wrapper", check.path)
}
if strings.Contains(source, `fmt.Errorf("连接建立后验证失败:%w", err)`) {
t.Fatalf("%s still contains raw verify-connection wrapper", check.path)
}
for _, required := range check.requiredTexts {
if !strings.Contains(source, required) {
t.Fatalf("%s does not reference i18n helper %q", check.path, required)
}
}
}
}
func TestDatabaseConnectionWrapperCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.connection_open_failed_prefix",
"db.backend.error.connection_verify_failed_prefix",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing database connection wrapper key %q", language, key)
}
}
}
}
func TestFormatCustomDriverOpenErrorUsesCurrentLanguageForUnknownDrivers(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
cases := []struct {
name string
driver string
base error
want string
}{
{
name: "system odbc driver",
driver: "InterSystems IRIS ODBC35",
base: errors.New(`sql: unknown driver "InterSystems IRIS ODBC35" (forgotten import?)`),
want: `Failed to open database connection: custom connections do not support entering the system ODBC/JDBC driver name "InterSystems IRIS ODBC35" directly. Enter a Go database/sql driver name already registered by GoNavi. The current build does not register a generic ODBC driver, so connecting to InterSystems IRIS through "InterSystems IRIS ODBC35" is not supported yet: sql: unknown driver "InterSystems IRIS ODBC35" (forgotten import?)`,
},
{
name: "unregistered go driver",
driver: "not-a-registered-go-driver",
base: errors.New(`sql: unknown driver "not-a-registered-go-driver" (forgotten import?)`),
want: `Failed to open database connection: the custom connection driver "not-a-registered-go-driver" is not registered in GoNavi. Enter a registered Go database/sql driver name instead of a system ODBC/JDBC driver name: sql: unknown driver "not-a-registered-go-driver" (forgotten import?)`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := formatCustomDriverOpenError(tc.driver, tc.base)
if err == nil {
t.Fatal("expected wrapped custom driver open error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
if !errors.Is(err, tc.base) {
t.Fatal("expected wrapped custom driver error to preserve cause")
}
if strings.Contains(err.Error(), "自定义连接") || strings.Contains(err.Error(), "未注册通用 ODBC 驱动") {
t.Fatalf("expected no raw Chinese custom driver guidance, got %q", err.Error())
}
})
}
}
func TestFormatCustomDriverOpenErrorSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("custom_impl.go")
if err != nil {
t.Fatalf("read custom_impl.go: %v", err)
}
source := string(sourceBytes)
functionSource := databaseFunctionSource(t, source, "func formatCustomDriverOpenError(driver string, err error) error")
for _, rawMessage := range []string{
`fmt.Errorf("打开数据库连接失败:自定义连接不支持直接填写系统 ODBC/JDBC 驱动名 %q请填写 GoNavi 已注册的 Go database/sql 驱动名。当前版本未注册通用 ODBC 驱动,因此暂不支持通过 %q 连接 InterSystems IRIS%w", driver, driver, err)`,
`fmt.Errorf("打开数据库连接失败:自定义连接驱动 %q 未在 GoNavi 中注册;请填写已注册的 Go database/sql 驱动名,不能填写系统 ODBC/JDBC 驱动名:%w", driver, err)`,
} {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("formatCustomDriverOpenError still contains raw custom driver guidance %q", rawMessage)
}
}
for _, key := range []string{
"db.backend.error.custom_driver_system_odbc_unsupported_prefix",
"db.backend.error.custom_driver_unregistered_prefix",
} {
if !strings.Contains(functionSource, key) {
t.Fatalf("formatCustomDriverOpenError does not reference i18n key %q", key)
}
}
}
func TestCustomDriverOpenErrorCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.custom_driver_system_odbc_unsupported_prefix",
"db.backend.error.custom_driver_unregistered_prefix",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing custom driver open error key %q", language, key)
}
}
}
}
func TestNewDatabaseUnsupportedTypeUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
dbType := "mystery-driver"
_, err := NewDatabase(dbType)
if err == nil {
t.Fatal("expected unsupported database type error")
}
want := "Unsupported database type: mystery-driver"
if err.Error() != want {
t.Fatalf("expected localized unsupported database type error %q, got %q", want, err.Error())
}
rawUnsupportedDatabaseTypeText := "\u4e0d\u652f\u6301\u7684\u6570\u636e\u5e93\u7c7b\u578b"
if strings.Contains(err.Error(), rawUnsupportedDatabaseTypeText) {
t.Fatalf("expected no Chinese unsupported database type text, got %q", err.Error())
}
}
func TestNewDatabaseUnsupportedTypeSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("database.go")
if err != nil {
t.Fatalf("read database.go: %v", err)
}
source := string(sourceBytes)
functionSource := databaseFunctionSource(t, source, "func NewDatabase(dbType string) (Database, error)")
rawUnsupportedDatabaseTypeText := "\u4e0d\u652f\u6301\u7684\u6570\u636e\u5e93\u7c7b\u578b"
rawUnsupportedDatabaseTypeSnippet := `fmt.Errorf("` + rawUnsupportedDatabaseTypeText + `%s", dbType)`
if strings.Contains(functionSource, rawUnsupportedDatabaseTypeSnippet) {
t.Fatal("NewDatabase still contains raw unsupported database type text")
}
if !strings.Contains(functionSource, "db.backend.error.unsupported_database_type") {
t.Fatal("NewDatabase does not reference db.backend.error.unsupported_database_type")
}
}
func TestNewDatabaseUnsupportedTypeCatalogKeyExists(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
if strings.TrimSpace(catalog["db.backend.error.unsupported_database_type"]) == "" {
t.Fatalf("%s catalog missing unsupported database type key", language)
}
}
}
func TestTransactionExecerStateErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
cases := []struct {
name string
call func() error
want string
unexpected string
}{
{
name: "sql conn transaction already finished",
call: func() error {
_, err := (&sqlConnTransactionExecer{
conn: new(sql.Conn),
done: true,
}).activeConn()
return err
},
want: "Transaction has already finished",
unexpected: rawTransactionAlreadyFinishedText,
},
{
name: "sql tx transaction not open",
call: func() error {
_, err := (&sqlTxStatementExecer{}).activeTx()
return err
},
want: "Transaction is not open",
unexpected: rawTransactionNotOpenText,
},
{
name: "sql tx transaction already finished",
call: func() error {
_, err := (&sqlTxStatementExecer{
tx: new(sql.Tx),
done: true,
}).activeTx()
return err
},
want: "Transaction has already finished",
unexpected: rawTransactionAlreadyFinishedText,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected localized transaction state error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
if strings.Contains(err.Error(), tc.unexpected) {
t.Fatalf("expected no raw Chinese transaction state text, got %q", err.Error())
}
})
}
}
func TestDatabaseTransactionStateSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("database.go")
if err != nil {
t.Fatalf("read database.go: %v", err)
}
source := string(sourceBytes)
checks := map[string][]string{
"func (e *sqlConnTransactionExecer) activeConn() (*sql.Conn, error)": {
"db.backend.error.connection_not_open",
"db.backend.error.transaction_already_finished",
},
"func (e *sqlTxStatementExecer) activeTx() (*sql.Tx, error)": {
"db.backend.error.transaction_not_open",
"db.backend.error.transaction_already_finished",
},
}
for signature, keys := range checks {
functionSource := databaseFunctionSource(t, source, signature)
for _, rawMessage := range []string{
`fmt.Errorf("` + rawTransactionNotOpenText + `")`,
`fmt.Errorf("` + rawTransactionAlreadyFinishedText + `")`,
} {
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("%s still contains raw transaction state text %q", signature, rawMessage)
}
}
for _, key := range keys {
if !strings.Contains(functionSource, key) {
t.Fatalf("%s does not reference i18n key %q", signature, key)
}
}
}
}
func TestDatabaseTransactionStateCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.transaction_not_open",
"db.backend.error.transaction_already_finished",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing database transaction state key %q", language, key)
}
}
}
}

View File

@@ -20,6 +20,18 @@ const (
peCOFFHeaderSize = 20
)
type driverAgentArchMismatchError struct {
fileLabel string
processLabel string
}
func (e *driverAgentArchMismatchError) Error() string {
if e == nil {
return "driver agent architecture is incompatible"
}
return fmt.Sprintf("driver agent architecture is incompatible (file=%s, current process=%s)", e.fileLabel, e.processLabel)
}
func windowsMachineLabel(machine uint16) string {
switch machine {
case peMachineI386:
@@ -61,7 +73,10 @@ func validateWindowsExecutableMachineForArch(pathText string, goarch string) err
return nil
}
if machine != expectedMachine {
return fmt.Errorf("可执行文件架构不兼容(文件=%s当前进程=%s", windowsMachineLabel(machine), expectedLabel)
return &driverAgentArchMismatchError{
fileLabel: windowsMachineLabel(machine),
processLabel: expectedLabel,
}
}
return nil
}

View File

@@ -0,0 +1,56 @@
package db
import (
"sync"
"GoNavi-Wails/shared/i18n"
)
var (
driverRuntimeTextMu sync.RWMutex
driverRuntimeTextLanguage = i18n.LanguageZhCN
driverRuntimeTextLocalizer *i18n.Localizer
)
func SetBackendLanguage(language i18n.Language) {
normalized, ok := i18n.NormalizeLanguage(string(language))
if !ok {
return
}
driverRuntimeTextMu.Lock()
defer driverRuntimeTextMu.Unlock()
driverRuntimeTextLanguage = normalized
if driverRuntimeTextLocalizer == nil {
localizer, err := i18n.NewLocalizer(normalized)
if err != nil {
return
}
driverRuntimeTextLocalizer = localizer
return
}
driverRuntimeTextLocalizer.SetLanguage(normalized)
}
func localizedDriverRuntimeText(key string, params map[string]any) string {
driverRuntimeTextMu.RLock()
if driverRuntimeTextLocalizer != nil {
text := driverRuntimeTextLocalizer.T(key, params)
driverRuntimeTextMu.RUnlock()
return text
}
driverRuntimeTextMu.RUnlock()
driverRuntimeTextMu.Lock()
defer driverRuntimeTextMu.Unlock()
if driverRuntimeTextLocalizer == nil {
localizer, err := i18n.NewLocalizer(driverRuntimeTextLanguage)
if err != nil {
return key
}
driverRuntimeTextLocalizer = localizer
}
return driverRuntimeTextLocalizer.T(key, params)
}

View File

@@ -1,6 +1,7 @@
package db
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -208,7 +209,9 @@ func resolveExternalDriverRoot(downloadDir string) (string, error) {
root = abs
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", fmt.Errorf("创建驱动目录失败:%w", err)
return "", fmt.Errorf("%s%w", localizedDriverRuntimeText("driver_manager.backend.error.create_directory_failed", map[string]any{
"detail": "",
}), err)
}
return root, nil
}
@@ -265,16 +268,28 @@ func optionalGoDriverRuntimeReady(driverType string) (bool, string) {
if !IsOptionalGoDriver(normalized) {
return true, ""
}
displayName := driverDisplayName(normalized)
executablePath, err := ResolveOptionalDriverAgentExecutablePath("", normalized)
if err != nil {
return false, fmt.Sprintf("%s 驱动代理路径解析失败,请在驱动管理中重新安装启用", driverDisplayName(normalized))
return false, localizedDriverRuntimeText("driver_manager.backend.status.agent_path_failed", map[string]any{"name": displayName})
}
info, statErr := os.Stat(executablePath)
if statErr != nil || info.IsDir() {
return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized))
return false, localizedDriverRuntimeText("driver_manager.backend.status.agent_missing", map[string]any{"name": displayName})
}
if validateErr := ValidateOptionalDriverAgentExecutable(normalized, executablePath); validateErr != nil {
return false, fmt.Sprintf("%s请在驱动管理中重新安装启用", validateErr.Error())
var archErr *driverAgentArchMismatchError
if errors.As(validateErr, &archErr) {
return false, localizedDriverRuntimeText("driver_manager.backend.status.agent_arch_incompatible_detail", map[string]any{
"name": displayName,
"file": archErr.fileLabel,
"process": archErr.processLabel,
})
}
return false, localizedDriverRuntimeText("driver_manager.backend.status.agent_unavailable_reinstall", map[string]any{
"name": displayName,
"detail": validateErr.Error(),
})
}
return true, ""
}
@@ -283,7 +298,7 @@ func optionalGoDriverRuntimeReady(driverType string) (bool, string) {
func DriverRuntimeSupportStatus(driverType string) (bool, string) {
normalized := normalizeRuntimeDriverType(driverType)
if normalized == "" {
return false, "未识别的数据源类型"
return false, localizedDriverRuntimeText("driver_manager.backend.status.unrecognized_driver_type", nil)
}
if normalized == "custom" {
return true, ""
@@ -292,8 +307,9 @@ func DriverRuntimeSupportStatus(driverType string) (bool, string) {
return true, ""
}
if IsOptionalGoDriver(normalized) {
displayName := driverDisplayName(normalized)
if !IsOptionalGoDriverBuildIncluded(normalized) {
return false, fmt.Sprintf("%s 当前发行包为精简构建,未内置该驱动;如需使用请安装 Full 版", driverDisplayName(normalized))
return false, localizedDriverRuntimeText("driver_manager.backend.status.slim_build_required", map[string]any{"name": displayName})
}
if optionalGoDriverInstalled(normalized) {
if ready, reason := optionalGoDriverRuntimeReady(normalized); !ready {
@@ -301,7 +317,7 @@ func DriverRuntimeSupportStatus(driverType string) (bool, string) {
}
return true, ""
}
return false, fmt.Sprintf("%s 纯 Go 驱动未启用,请先在驱动管理中点击“安装启用”", driverDisplayName(normalized))
return false, localizedDriverRuntimeText("driver_manager.backend.status.optional_disabled", map[string]any{"name": displayName})
}
return true, ""
}

View File

@@ -4,9 +4,26 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
func driverSupportFunctionSource(t *testing.T, source string, signature string) string {
t.Helper()
start := strings.Index(source, signature)
if start < 0 {
t.Fatalf("driver_support.go missing function signature %q", signature)
}
rest := source[start+len(signature):]
end := strings.Index(rest, "\nfunc ")
if end < 0 {
return source[start:]
}
return source[start : start+len(signature)+end]
}
func TestPostgresRuntimeSupportRequiresInstallMarker(t *testing.T) {
tmpDir := t.TempDir()
SetExternalDriverDownloadDirectory(tmpDir)
@@ -166,3 +183,117 @@ func TestGoldenDBBuiltinDatabaseFactoryUsesMySQLImplementation(t *testing.T) {
t.Fatalf("expected goldendb to reuse MySQLDB implementation, got %T", dbInst)
}
}
func TestDriverRuntimeSupportStatusUsesCurrentLanguageForUnrecognizedDriverType(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
supported, reason := DriverRuntimeSupportStatus(" ")
if supported {
t.Fatal("expected blank driver type to be unsupported")
}
if reason != "Unrecognized data source type" {
t.Fatalf("expected English unrecognized-driver reason, got %q", reason)
}
}
func TestDriverRuntimeSupportStatusUsesCurrentLanguageForOptionalDriverDisabledState(t *testing.T) {
tmpDir := t.TempDir()
SetExternalDriverDownloadDirectory(tmpDir)
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
supported, reason := DriverRuntimeSupportStatus("mariadb")
if supported {
t.Fatal("expected mariadb to stay unavailable without installation marker")
}
if !IsOptionalGoDriverBuildIncluded("mariadb") {
want := "MariaDB is not included in the current slim build. Install the Full edition to use this driver."
if reason != want {
t.Fatalf("expected English slim-build reason %q, got %q", want, reason)
}
return
}
want := "MariaDB Go driver is not enabled; install and enable it in Driver Manager."
if reason != want {
t.Fatalf("expected English disabled-driver reason %q, got %q", want, reason)
}
}
func TestDriverRuntimeSupportStatusUsesCurrentLanguageForMissingOptionalDriverAgent(t *testing.T) {
if !IsOptionalGoDriverBuildIncluded("mariadb") {
t.Skip("mariadb is not included in the current slim build")
}
tmpDir := t.TempDir()
SetExternalDriverDownloadDirectory(tmpDir)
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
markerPath, err := ResolveOptionalGoDriverMarkerPath(tmpDir, "mariadb")
if err != nil {
t.Fatalf("resolve marker path failed: %v", err)
}
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
t.Fatalf("create marker directory failed: %v", err)
}
if err := os.WriteFile(markerPath, []byte("{}"), 0o644); err != nil {
t.Fatalf("write marker failed: %v", err)
}
supported, reason := DriverRuntimeSupportStatus("mariadb")
if supported {
t.Fatal("expected mariadb to stay unavailable when the driver agent executable is missing")
}
want := "MariaDB driver agent is missing; reinstall and enable it in Driver Manager."
if reason != want {
t.Fatalf("expected English missing-agent reason %q, got %q", want, reason)
}
}
func TestResolveExternalDriverRootSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("driver_support.go")
if err != nil {
t.Fatalf("read driver_support.go: %v", err)
}
source := string(sourceBytes)
functionSource := driverSupportFunctionSource(t, source, "func resolveExternalDriverRoot(downloadDir string) (string, error)")
rawCreateDirectoryWrapper := "fmt.Errorf(\"\\u521b\\u5efa\\u9a71\\u52a8\\u76ee\\u5f55\\u5931\\u8d25\\uff1a%w\", err)"
if strings.Contains(functionSource, rawCreateDirectoryWrapper) {
t.Fatal("resolveExternalDriverRoot still contains raw Chinese create-directory wrapper")
}
if !strings.Contains(functionSource, "driver_manager.backend.error.create_directory_failed") {
t.Fatal("resolveExternalDriverRoot does not reference driver_manager.backend.error.create_directory_failed")
}
}
func TestResolveExternalDriverRootUsesCurrentLanguageForCreateDirectoryFailure(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tmpDir := t.TempDir()
blocker := filepath.Join(tmpDir, "driver-root-blocker")
if err := os.WriteFile(blocker, []byte("blocker"), 0o644); err != nil {
t.Fatalf("write blocker file: %v", err)
}
_, err := ResolveExternalDriverRoot(filepath.Join(blocker, "nested"))
if err == nil {
t.Fatal("expected create-directory failure")
}
if !strings.Contains(err.Error(), "Failed to create driver directory:") {
t.Fatalf("expected English create-directory wrapper, got %q", err.Error())
}
if strings.Contains(err.Error(), "\u521b\u5efa\u9a71\u52a8\u76ee\u5f55\u5931\u8d25") {
t.Fatalf("expected no Chinese create-directory wrapper in en-US mode, got %q", err.Error())
}
}

View File

@@ -7,6 +7,7 @@ import (
"database/sql"
"database/sql/driver"
"fmt"
"strings"
"sync"
"testing"
@@ -26,6 +27,8 @@ type duckdbRecordingState struct {
mu sync.Mutex
execQueries []string
execArgs [][]driver.NamedValue
failDelete error
failUpdate error
}
func (s *duckdbRecordingState) snapshotExecQueries() []string {
@@ -73,6 +76,12 @@ func (c *duckdbRecordingConn) ExecContext(_ context.Context, query string, args
defer c.state.mu.Unlock()
c.state.execQueries = append(c.state.execQueries, query)
c.state.execArgs = append(c.state.execArgs, append([]driver.NamedValue(nil), args...))
if strings.HasPrefix(query, "DELETE FROM ") && c.state.failDelete != nil {
return nil, c.state.failDelete
}
if strings.HasPrefix(query, "UPDATE ") && c.state.failUpdate != nil {
return nil, c.state.failUpdate
}
return driver.RowsAffected(1), nil
}
@@ -151,10 +160,13 @@ func TestDuckDBApplyChangesUsesUnquotedRowIDLocator(t *testing.T) {
if len(args) != 2 || len(args[0]) != 1 || len(args[1]) != 2 {
t.Fatalf("执行参数数量不符合预期: %#v", args)
}
if args[0][0].Value != 21 {
if got, ok := args[0][0].Value.(int64); !ok || got != 21 {
t.Fatalf("删除 rowid 参数错误: %#v", args[0])
}
if args[1][0].Value != "renamed" || args[1][1].Value != 17 {
if args[1][0].Value != "renamed" {
t.Fatalf("更新参数错误: %#v", args[1])
}
if got, ok := args[1][1].Value.(int64); !ok || got != 17 {
t.Fatalf("更新参数错误: %#v", args[1])
}
}

View File

@@ -0,0 +1,290 @@
//go:build gonavi_full_drivers || gonavi_duckdb_driver
package db
import (
"errors"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"os"
"runtime"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
type duckDBI18nEmptyRowsDriver struct{}
type duckDBI18nEmptyRowsConn struct{}
type duckDBI18nEmptyRowsStmt struct{}
type duckDBI18nEmptyRowsRows struct{}
var registerDuckDBI18nEmptyRowsDriverOnce sync.Once
func (duckDBI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return duckDBI18nEmptyRowsConn{}, nil
}
func (duckDBI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return duckDBI18nEmptyRowsStmt{}, nil
}
func (duckDBI18nEmptyRowsConn) Close() error { return nil }
func (duckDBI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, fmt.Errorf("transactions are not supported in duckdb i18n empty rows test driver")
}
func (duckDBI18nEmptyRowsStmt) Close() error { return nil }
func (duckDBI18nEmptyRowsStmt) NumInput() int { return -1 }
func (duckDBI18nEmptyRowsStmt) Exec(args []driver.Value) (driver.Result, error) {
return nil, fmt.Errorf("exec is not supported in duckdb i18n empty rows test driver")
}
func (duckDBI18nEmptyRowsStmt) Query(args []driver.Value) (driver.Rows, error) {
return duckDBI18nEmptyRowsRows{}, nil
}
func (duckDBI18nEmptyRowsRows) Columns() []string {
return []string{"sql"}
}
func (duckDBI18nEmptyRowsRows) Close() error { return nil }
func (duckDBI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openDuckDBI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerDuckDBI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("duckdb_i18n_empty_rows", duckDBI18nEmptyRowsDriver{})
})
conn, err := sql.Open("duckdb_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open duckdb_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func TestDuckDBRuntimeUsesCurrentLanguageForConnectionNotOpen(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
err := (&DuckDB{}).Ping()
if err == nil {
t.Fatal("expected Ping to fail when DuckDB connection is not open")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English not-open error, got %q", err.Error())
}
}
func TestDuckDBBuildSupportStatusUsesCurrentLanguageWhenDriverIsUnavailable(t *testing.T) {
if supported, _ := duckDBBuildSupportStatus(); supported {
t.Skip("current build already includes DuckDB runtime support")
}
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
wantReason := fmt.Sprintf(
"The current build does not include the DuckDB driver (platform=%s/%s). Enable CGO and use a supported platform (darwin/linux amd64|arm64, windows/amd64), or provide a custom library via -tags duckdb_use_lib / duckdb_use_static_lib",
runtime.GOOS,
runtime.GOARCH,
)
supported, reason := duckDBBuildSupportStatus()
if supported {
t.Fatal("expected DuckDB build support to stay unavailable in this test environment")
}
if reason != wantReason {
t.Fatalf("expected English DuckDB-unavailable reason %q, got %q", wantReason, reason)
}
err := (&DuckDB{}).Connect(connection.ConnectionConfig{Type: "duckdb"})
if err == nil {
t.Fatal("expected DuckDB connect to fail when runtime support is unavailable")
}
wantConnectError := "DuckDB driver is unavailable: " + wantReason
if err.Error() != wantConnectError {
t.Fatalf("expected English DuckDB connect error %q, got %q", wantConnectError, err.Error())
}
}
func TestDuckDBDDLMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
duck := &DuckDB{}
tests := []struct {
name string
call func() error
want string
}{
{
name: "create statement table name required",
call: func() error {
_, err := duck.GetCreateStatement("", " ")
return err
},
want: "Table name is required",
},
{
name: "create statement not found",
call: func() error {
_, err := (&DuckDB{conn: openDuckDBI18nEmptyRowsDB(t)}).GetCreateStatement("main", "orders")
return err
},
want: "The CREATE TABLE statement was not found",
},
{
name: "columns table name required",
call: func() error {
_, err := duck.GetColumns("", " ")
return err
},
want: "Table name is required",
},
{
name: "indexes table name required",
call: func() error {
_, err := duck.GetIndexes("", " ")
return err
},
want: "Table name is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected DuckDB DDL metadata call to fail")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
})
}
}
func TestDuckDBApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
t.Run("delete failure", func(t *testing.T) {
dbConn, state := openDuckDBRecordingDB(t)
state.failDelete = errors.New("delete blocked")
duckdb := &DuckDB{conn: dbConn}
err := duckdb.ApplyChanges("main.events", connection.ChangeSet{
Deletes: []map[string]interface{}{
{"id": 1},
},
})
if err == nil {
t.Fatal("expected delete failure to bubble up")
}
if err.Error() != "Delete failed: delete blocked" {
t.Fatalf("expected English delete failure, got %q", err.Error())
}
})
t.Run("update key condition required", func(t *testing.T) {
dbConn, _ := openDuckDBRecordingDB(t)
duckdb := &DuckDB{conn: dbConn}
err := duckdb.ApplyChanges("main.events", connection.ChangeSet{
Updates: []connection.UpdateRow{{
Values: map[string]interface{}{
"name": "renamed",
},
}},
})
if err == nil {
t.Fatal("expected update without keys to fail")
}
if err.Error() != "Update operation requires key conditions" {
t.Fatalf("expected English update-key failure, got %q", err.Error())
}
})
t.Run("update failure", func(t *testing.T) {
dbConn, state := openDuckDBRecordingDB(t)
state.failUpdate = errors.New("update blocked")
duckdb := &DuckDB{conn: dbConn}
err := duckdb.ApplyChanges("main.events", connection.ChangeSet{
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{
"id": 1,
},
Values: map[string]interface{}{
"name": "renamed",
},
}},
})
if err == nil {
t.Fatal("expected update failure to bubble up")
}
if err.Error() != "Update failed: update blocked" {
t.Fatalf("expected English update failure, got %q", err.Error())
}
})
}
func TestDuckDBUserVisibleRuntimeErrorsDoNotReintroduceInlineChinese(t *testing.T) {
t.Helper()
files := []string{"duckdb_impl.go", "duckdb_platform_unsupported.go"}
disallowed := []string{
string([]rune{0x44, 0x75, 0x63, 0x6b, 0x44, 0x42, 0x20, 0x9a71, 0x52a8, 0x4e0d, 0x53ef, 0x7528}),
string([]rune{0x6253, 0x5f00, 0x6570, 0x636e, 0x5e93, 0x8fde, 0x63a5, 0x5931, 0x8d25}),
string([]rune{0x8fde, 0x63a5, 0x5efa, 0x7acb, 0x540e, 0x9a8c, 0x8bc1, 0x5931, 0x8d25}),
string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00}),
string([]rune{0x5f53, 0x524d, 0x6784, 0x5efa, 0x4e0d, 0x5305, 0x542b, 0x20, 0x44, 0x75, 0x63, 0x6b, 0x44, 0x42, 0x20, 0x9a71, 0x52a8}),
string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a}),
string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5}),
string([]rune{0x5220, 0x9664, 0x5931, 0x8d25}),
string([]rune{0x66f4, 0x65b0, 0x64cd, 0x4f5c, 0x9700, 0x8981, 0x4e3b, 0x952e, 0x6761, 0x4ef6}),
string([]rune{0x66f4, 0x65b0, 0x5931, 0x8d25}),
}
for _, file := range files {
content, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s failed: %v", file, err)
}
source := string(content)
for _, raw := range disallowed {
if strings.Contains(source, raw) {
t.Fatalf("%s still contains inline user-visible Chinese raw: %s", file, raw)
}
}
}
}

View File

@@ -5,6 +5,7 @@ package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
@@ -18,9 +19,41 @@ type DuckDB struct {
pingTimeout time.Duration
}
func duckDBRuntimeError(key string, params map[string]any) error {
return errors.New(localizedDriverRuntimeText(key, params))
}
func duckDBWrapRuntimeError(prefixKey string, err error) error {
return fmt.Errorf("%s%w", localizedDriverRuntimeText(prefixKey, nil), err)
}
func duckDBConnectionNotOpenError() error {
return duckDBRuntimeError("db.backend.error.connection_not_open", nil)
}
func duckDBTableNameRequiredError() error {
return duckDBRuntimeError("db.backend.error.table_name_required", nil)
}
func duckDBCreateTableStatementNotFoundError() error {
return duckDBRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func duckDBDeleteFailedError(err error) error {
return duckDBRuntimeError("db.backend.error.row_delete_failed", map[string]any{"detail": err.Error()})
}
func duckDBUpdateKeyConditionsRequiredError() error {
return duckDBRuntimeError("db.backend.error.row_update_key_conditions_required", nil)
}
func duckDBUpdateFailedError(err error) error {
return duckDBRuntimeError("db.backend.error.row_update_failed", map[string]any{"detail": err.Error()})
}
func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := duckDBBuildSupportStatus(); !supported {
return fmt.Errorf("DuckDB 驱动不可用:%s", reason)
return duckDBRuntimeError("db.backend.error.duckdb_driver_unavailable", map[string]any{"detail": reason})
}
dsn := strings.TrimSpace(config.Host)
@@ -33,7 +66,7 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
db, err := sql.Open("duckdb", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
return duckDBWrapRuntimeError("db.backend.error.connection_open_failed_prefix", err)
}
d.conn = db
d.pingTimeout = getConnectTimeout(config)
@@ -41,7 +74,7 @@ func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
if err := d.Ping(); err != nil {
_ = db.Close()
d.conn = nil
return fmt.Errorf("连接建立后验证失败:%w", err)
return duckDBWrapRuntimeError("db.backend.error.connection_verify_failed_prefix", err)
}
return nil
}
@@ -55,7 +88,7 @@ func (d *DuckDB) Close() error {
func (d *DuckDB) Ping() error {
if d.conn == nil {
return fmt.Errorf("连接未打开")
return duckDBConnectionNotOpenError()
}
timeout := d.pingTimeout
if timeout <= 0 {
@@ -68,7 +101,7 @@ func (d *DuckDB) Ping() error {
func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, duckDBConnectionNotOpenError()
}
rows, err := d.conn.QueryContext(ctx, query)
if err != nil {
@@ -80,7 +113,7 @@ func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]i
func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) {
if d.conn == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, duckDBConnectionNotOpenError()
}
rows, err := d.conn.Query(query)
if err != nil {
@@ -92,7 +125,7 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error)
func (d *DuckDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, duckDBConnectionNotOpenError()
}
res, err := d.conn.ExecContext(ctx, query)
if err != nil {
@@ -103,7 +136,7 @@ func (d *DuckDB) ExecBatchContext(ctx context.Context, query string) (int64, err
func (d *DuckDB) OpenSessionExecer(ctx context.Context) (StatementExecer, error) {
if d.conn == nil {
return nil, fmt.Errorf("连接未打开")
return nil, duckDBConnectionNotOpenError()
}
conn, err := d.conn.Conn(ctx)
if err != nil {
@@ -114,7 +147,7 @@ func (d *DuckDB) OpenSessionExecer(ctx context.Context) (StatementExecer, error)
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, duckDBConnectionNotOpenError()
}
res, err := d.conn.ExecContext(ctx, query)
if err != nil {
@@ -125,7 +158,7 @@ func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
func (d *DuckDB) Exec(query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("连接未打开")
return 0, duckDBConnectionNotOpenError()
}
res, err := d.conn.Exec(query)
if err != nil {
@@ -210,7 +243,7 @@ ORDER BY table_catalog, table_schema, table_name`, escapeDuckDBLiteral(path.Cata
func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
path := normalizeDuckDBObjectPath(dbName, tableName)
if path.Object == "" {
return "", fmt.Errorf("表名不能为空")
return "", duckDBTableNameRequiredError()
}
escapedTable := escapeDuckDBLiteral(path.Object)
@@ -251,13 +284,13 @@ func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
}
}
return "", fmt.Errorf("未找到建表语句")
return "", duckDBCreateTableStatementNotFoundError()
}
func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
path := normalizeDuckDBObjectPath(dbName, tableName)
if path.Object == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, duckDBTableNameRequiredError()
}
query := fmt.Sprintf(`
@@ -353,7 +386,7 @@ ORDER BY table_catalog, table_schema, table_name, ordinal_position`, escapeDuckD
func (d *DuckDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
path := normalizeDuckDBObjectPath(dbName, tableName)
if path.Object == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, duckDBTableNameRequiredError()
}
constraintQuery := buildDuckDBConstraintMetadataQuery(path, true)
@@ -395,7 +428,7 @@ func (d *DuckDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefi
func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if d.conn == nil {
return fmt.Errorf("连接未打开")
return duckDBConnectionNotOpenError()
}
tx, err := d.conn.Begin()
@@ -446,7 +479,7 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("删除失败:%v", err)
return duckDBDeleteFailedError(err)
}
}
@@ -464,12 +497,12 @@ func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
wheres, whereArgs := buildWhere(update.Keys)
args = append(args, whereArgs...)
if len(wheres) == 0 {
return fmt.Errorf("更新操作需要主键条件")
return duckDBUpdateKeyConditionsRequiredError()
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
if _, err := tx.Exec(query, args...); err != nil {
return fmt.Errorf("更新失败:%v", err)
return duckDBUpdateFailedError(err)
}
}

View File

@@ -3,10 +3,11 @@
package db
import (
"fmt"
"runtime"
)
func duckDBBuildSupportStatus() (bool, string) {
return false, fmt.Sprintf("当前构建不包含 DuckDB 驱动(平台=%s/%s。需要启用 CGO并使用受支持平台darwin/linux amd64|arm64、windows/amd64或通过 -tags duckdb_use_lib / duckdb_use_static_lib 提供自定义库", runtime.GOOS, runtime.GOARCH)
return false, localizedDriverRuntimeText("db.backend.error.duckdb_build_unavailable", map[string]any{
"platform": runtime.GOOS + "/" + runtime.GOARCH,
})
}

View File

@@ -0,0 +1,127 @@
//go:build gonavi_full_drivers || gonavi_elasticsearch_driver
package db
import (
"context"
"os"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
const rawElasticsearchConnectionNotOpenText = "\u8fde\u63a5\u672a\u6253\u5f00"
func TestElasticsearchConnectionNotOpenUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
elasticsearchDB := &ElasticsearchDB{}
cases := []struct {
name string
call func() error
}{
{
name: "ping",
call: func() error {
return elasticsearchDB.Ping()
},
},
{
name: "query",
call: func() error {
_, _, err := elasticsearchDB.Query("test")
return err
},
},
{
name: "query_context",
call: func() error {
_, _, err := elasticsearchDB.QueryContext(context.Background(), "test")
return err
},
},
{
name: "get_databases",
call: func() error {
_, err := elasticsearchDB.GetDatabases()
return err
},
},
{
name: "get_tables",
call: func() error {
_, err := elasticsearchDB.GetTables("test")
return err
},
},
{
name: "get_create_statement",
call: func() error {
_, err := elasticsearchDB.GetCreateStatement("test", "")
return err
},
},
{
name: "get_columns",
call: func() error {
_, err := elasticsearchDB.GetColumns("test", "")
return err
},
},
{
name: "get_all_columns",
call: func() error {
_, err := elasticsearchDB.GetAllColumns("test")
return err
},
},
{
name: "get_indexes",
call: func() error {
_, err := elasticsearchDB.GetIndexes("test", "")
return err
},
},
{
name: "apply_changes",
call: func() error {
return elasticsearchDB.ApplyChanges("test", connection.ChangeSet{})
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawElasticsearchConnectionNotOpenText) {
t.Fatalf("expected no raw Chinese connection-not-open text, got %q", err.Error())
}
})
}
}
func TestElasticsearchConnectionNotOpenSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("elasticsearch_impl.go")
if err != nil {
t.Fatalf("read elasticsearch_impl.go: %v", err)
}
source := string(sourceBytes)
if strings.Contains(source, `fmt.Errorf("`+rawElasticsearchConnectionNotOpenText+`")`) {
t.Fatal("elasticsearch_impl.go still contains raw connection-not-open text")
}
if !strings.Contains(source, "db.backend.error.connection_not_open") {
t.Fatal("elasticsearch_impl.go does not reference db.backend.error.connection_not_open")
}
}

View File

@@ -138,7 +138,7 @@ func (e *ElasticsearchDB) Close() error {
// Ping 检测 Elasticsearch 连通性。
func (e *ElasticsearchDB) Ping() error {
if e.client == nil {
return fmt.Errorf("连接未打开")
return localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
timeout := e.pingTimeout
if timeout <= 0 {
@@ -174,7 +174,7 @@ func (e *ElasticsearchDB) QueryContext(ctx context.Context, query string) ([]map
// queryWithContext 查询的核心实现,被 Query 和 QueryContext 共用。
func (e *ElasticsearchDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if e.client == nil {
return nil, nil, fmt.Errorf("连接未打开")
return nil, nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
query = strings.TrimSpace(query)
@@ -325,7 +325,7 @@ func (e *ElasticsearchDB) ExecContext(_ context.Context, _ string) (int64, error
// GetDatabases 列出所有 Elasticsearch 索引。
func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
if e.client == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -361,7 +361,7 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
// GetTables 对 ES 而言索引即表,返回索引自身名称及别名。
func (e *ElasticsearchDB) GetTables(dbName string) ([]string, error) {
if e.client == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
target := strings.TrimSpace(dbName)
@@ -381,7 +381,7 @@ func (e *ElasticsearchDB) GetTables(dbName string) ([]string, error) {
// GetCreateStatement 返回索引的 settings + mappings 组合 JSON。
func (e *ElasticsearchDB) GetCreateStatement(dbName, tableName string) (string, error) {
if e.client == nil {
return "", fmt.Errorf("连接未打开")
return "", localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
indexName := resolveEsIndexName(dbName, tableName, e.database)
@@ -421,7 +421,7 @@ func (e *ElasticsearchDB) GetCreateStatement(dbName, tableName string) (string,
// GetColumns 返回索引的 mapping 字段定义。
func (e *ElasticsearchDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
if e.client == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
indexName := resolveEsIndexName(dbName, tableName, e.database)
@@ -439,7 +439,7 @@ func (e *ElasticsearchDB) GetColumns(dbName, tableName string) ([]connection.Col
// GetAllColumns 返回索引的全部字段定义(带表名标识)。
func (e *ElasticsearchDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if e.client == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
target := strings.TrimSpace(dbName)
@@ -471,7 +471,7 @@ func (e *ElasticsearchDB) GetAllColumns(dbName string) ([]connection.ColumnDefin
// GetIndexes 返回索引的 settings 中定义的分片与副本信息。
func (e *ElasticsearchDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
if e.client == nil {
return nil, fmt.Errorf("连接未打开")
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
indexName := resolveEsIndexName(dbName, tableName, e.database)
@@ -625,7 +625,7 @@ func isESMetaField(name string) bool {
// ApplyChanges 实现 BatchApplier 接口,通过 ES _bulk API 批量提交增删改。
func (e *ElasticsearchDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if e.client == nil {
return fmt.Errorf("连接未打开")
return localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
indexName := resolveEsIndexName(tableName, "", e.database)

View File

@@ -105,7 +105,7 @@ func (g *GaussDB) getDSN(config connection.ConnectionConfig) string {
func (g *GaussDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := DriverRuntimeSupportStatus("gaussdb"); !supported {
if strings.TrimSpace(reason) == "" {
reason = "GaussDB 纯 Go 驱动未启用,请先在驱动管理中安装启用"
reason = localizedDriverRuntimeText("driver_manager.backend.status.optional_disabled", map[string]any{"name": "GaussDB"})
}
return fmt.Errorf("%s", reason)
}

View File

@@ -0,0 +1,86 @@
//go:build gonavi_full_drivers || gonavi_highgo_driver
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawHighGoTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
func TestHighGoMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
highgo := &HighGoDB{}
tests := []struct {
name string
call func() error
}{
{
name: "columns table name required",
call: func() error {
_, err := highgo.GetColumns("", " ")
return err
},
},
{
name: "indexes table name required",
call: func() error {
_, err := highgo.GetIndexes("", " ")
return err
},
},
{
name: "foreign keys table name required",
call: func() error {
_, err := highgo.GetForeignKeys("", " ")
return err
},
},
{
name: "triggers table name required",
call: func() error {
_, err := highgo.GetTriggers("", " ")
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected HighGo metadata call to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawHighGoTableNameRequiredText) {
t.Fatalf("expected no raw Chinese HighGo metadata text, got %q", err.Error())
}
})
}
}
func TestHighGoMetadataErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("highgo_impl.go")
if err != nil {
t.Fatalf("read highgo_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawHighGoTableNameRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("highgo_impl.go still contains raw HighGo metadata text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.table_name_required") {
t.Fatal("highgo_impl.go does not reference db.backend.error.table_name_required")
}
}

View File

@@ -303,7 +303,7 @@ func (h *HighGoDB) GetCreateStatement(dbName, tableName string) (string, error)
func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := h.Query(buildPGLikeColumnsMetadataQuery(schema, table))
@@ -317,7 +317,7 @@ func (h *HighGoDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
func (h *HighGoDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := h.Query(buildPGLikeIndexesMetadataQuery(schema, table))
@@ -335,7 +335,7 @@ func (h *HighGoDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -395,7 +395,7 @@ func (h *HighGoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }

View File

@@ -0,0 +1,74 @@
//go:build gonavi_full_drivers || gonavi_iris_driver
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawIRISTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
func TestIRISTableRefErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tests := []struct {
name string
raw string
}{
{name: "empty table", raw: " "},
{name: "empty qualified table", raw: `"APP". `},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parseIRISTableRef("USER", tc.raw)
if err == nil {
t.Fatal("expected IRIS table reference parsing to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawIRISTableNameRequiredText) {
t.Fatalf("expected no raw Chinese table-name-required text, got %q", err.Error())
}
})
}
}
func TestIRISTableNameRequiredSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("iris_impl.go")
if err != nil {
t.Fatalf("read iris_impl.go: %v", err)
}
source := string(sourceBytes)
functionSource := databaseFunctionSource(t, source, "func parseIRISTableRef(defaultSchema, raw string) (irisTableRef, error)")
rawMessage := `fmt.Errorf("` + rawIRISTableNameRequiredText + `")`
if strings.Contains(functionSource, rawMessage) {
t.Fatalf("parseIRISTableRef still contains raw IRIS table-name-required text %q", rawMessage)
}
if !strings.Contains(functionSource, "db.backend.error.table_name_required") {
t.Fatal("parseIRISTableRef does not reference db.backend.error.table_name_required")
}
}
func TestIRISTableNameRequiredCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
if strings.TrimSpace(catalog["db.backend.error.table_name_required"]) == "" {
t.Fatalf("%s catalog missing IRIS table-name-required key", language)
}
}
}

View File

@@ -139,12 +139,12 @@ func (i *IrisDB) Connect(config connection.ConnectionConfig) error {
db, err := sql.Open("iris", i.getDSN(runConfig))
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
return wrapDatabaseConnectionOpenError(err)
}
i.conn = db
i.pingTimeout = getConnectTimeout(runConfig)
if err := i.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
return wrapDatabaseConnectionVerifyError(err)
}
cleanupOnFailure = false
return nil
@@ -487,7 +487,7 @@ func (i *IrisDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if err := requireSingleRowAffected(res, "删除"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionDelete); err != nil {
return err
}
}
@@ -504,7 +504,7 @@ func (i *IrisDB) ApplyChanges(tableName string, changes connection.ChangeSet) er
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if err := requireSingleRowAffected(res, "更新"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionUpdate); err != nil {
return err
}
}
@@ -544,13 +544,13 @@ func buildIRISInfoSchemaWhereQuery(table string, ref irisTableRef) string {
func parseIRISTableRef(defaultSchema, raw string) (irisTableRef, error) {
text := strings.TrimSpace(raw)
if text == "" {
return irisTableRef{}, fmt.Errorf("表名不能为空")
return irisTableRef{}, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
if schemaPart, tablePart, ok := splitIRISTablePath(text); ok {
schema := cleanIRISIdentifier(schemaPart)
table := cleanIRISIdentifier(tablePart)
if table == "" {
return irisTableRef{}, fmt.Errorf("表名不能为空")
return irisTableRef{}, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
return irisTableRef{Schema: schema, Table: table}, nil
}

View File

@@ -0,0 +1,86 @@
//go:build gonavi_full_drivers || gonavi_kingbase_driver
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawKingbaseTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
func TestKingbaseMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
kingbase := &KingbaseDB{}
tests := []struct {
name string
call func() error
}{
{
name: "columns table name required",
call: func() error {
_, err := kingbase.GetColumns("", " ")
return err
},
},
{
name: "indexes table name required",
call: func() error {
_, err := kingbase.GetIndexes("", " ")
return err
},
},
{
name: "foreign keys table name required",
call: func() error {
_, err := kingbase.GetForeignKeys("", " ")
return err
},
},
{
name: "triggers table name required",
call: func() error {
_, err := kingbase.GetTriggers("", " ")
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected Kingbase metadata call to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawKingbaseTableNameRequiredText) {
t.Fatalf("expected no raw Chinese Kingbase metadata text, got %q", err.Error())
}
})
}
}
func TestKingbaseTableNameRequiredSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("kingbase_impl.go")
if err != nil {
t.Fatalf("read kingbase_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawKingbaseTableNameRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("kingbase_impl.go still contains raw Kingbase table-name-required text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.table_name_required") {
t.Fatal("kingbase_impl.go does not reference db.backend.error.table_name_required")
}
}

View File

@@ -496,7 +496,7 @@ func (k *KingbaseDB) GetCreateStatement(dbName, tableName string) (string, error
func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := k.Query(buildPGLikeColumnsMetadataQuery(schema, table))
@@ -510,7 +510,7 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
func (k *KingbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := k.Query(buildPGLikeIndexesMetadataQuery(schema, table))
@@ -537,7 +537,7 @@ func (k *KingbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
// 转义函数:处理单引号,移除双引号
@@ -619,7 +619,7 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
// 转义函数:处理单引号,移除双引号
@@ -673,7 +673,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
schema, table := splitKingbaseQualifiedTable(tableName)
if table == "" {
return fmt.Errorf("表名不能为空")
return localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
qualifiedTable := ""

View File

@@ -0,0 +1,153 @@
//go:build gonavi_full_drivers || gonavi_mariadb_driver
package db
import (
"database/sql"
"database/sql/driver"
"io"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/shared/i18n"
)
type mariaDBI18nEmptyRowsDriver struct{}
type mariaDBI18nEmptyRowsConn struct{}
type mariaDBI18nEmptyRowsStmt struct{}
type mariaDBI18nEmptyRowsRows struct{}
var registerMariaDBI18nEmptyRowsDriverOnce sync.Once
var rawMariaDBCreateStatementNotFoundText = string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5})
var rawMariaDBAllColumnsDatabaseRequiredText = string([]rune{0x83b7, 0x53d6, 0x5168, 0x90e8, 0x5217, 0x4fe1, 0x606f, 0x9700, 0x8981, 0x6307, 0x5b9a, 0x6570, 0x636e, 0x5e93, 0x540d, 0x79f0})
func (mariaDBI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return mariaDBI18nEmptyRowsConn{}, nil
}
func (mariaDBI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return mariaDBI18nEmptyRowsStmt{}, nil
}
func (mariaDBI18nEmptyRowsConn) Close() error { return nil }
func (mariaDBI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_not_open", nil)
}
func (mariaDBI18nEmptyRowsStmt) Close() error { return nil }
func (mariaDBI18nEmptyRowsStmt) NumInput() int { return -1 }
func (mariaDBI18nEmptyRowsStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.RowsAffected(0), nil
}
func (mariaDBI18nEmptyRowsStmt) Query(args []driver.Value) (driver.Rows, error) {
return mariaDBI18nEmptyRowsRows{}, nil
}
func (mariaDBI18nEmptyRowsRows) Columns() []string {
return []string{"Create Table"}
}
func (mariaDBI18nEmptyRowsRows) Close() error { return nil }
func (mariaDBI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openMariaDBI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerMariaDBI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("mariadb_i18n_empty_rows", mariaDBI18nEmptyRowsDriver{})
})
conn, err := sql.Open("mariadb_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open mariadb_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func TestMariaDBCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
mariaDB := &MariaDB{conn: openMariaDBI18nEmptyRowsDB(t)}
_, err := mariaDB.GetCreateStatement("app", "orders")
if err == nil {
t.Fatal("expected MariaDB GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawMariaDBCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
}
func TestMariaDBCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("mariadb_impl.go")
if err != nil {
t.Fatalf("read mariadb_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawMariaDBCreateStatementNotFoundText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("mariadb_impl.go still contains raw create-statement text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("mariadb_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
}
func TestMariaDBGetAllColumnsDatabaseRequiredUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
mariaDB := &MariaDB{}
_, err := mariaDB.GetAllColumns("")
if err == nil {
t.Fatal("expected MariaDB GetAllColumns to fail")
}
if err.Error() != "Database name is required" {
t.Fatalf("expected English database-name error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawMariaDBAllColumnsDatabaseRequiredText) {
t.Fatalf("expected no raw Chinese database-name text, got %q", err.Error())
}
}
func TestMariaDBGetAllColumnsDatabaseRequiredSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("mariadb_impl.go")
if err != nil {
t.Fatalf("read mariadb_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawMariaDBAllColumnsDatabaseRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("mariadb_impl.go still contains raw database-name text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.database_name_required") {
t.Fatal("mariadb_impl.go does not reference db.backend.error.database_name_required")
}
}

View File

@@ -47,13 +47,13 @@ func (m *MariaDB) Connect(config connection.ConnectionConfig) error {
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
return wrapDatabaseConnectionOpenError(err)
}
m.conn = db
m.pingTimeout = getConnectTimeout(config)
if err := m.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
return wrapDatabaseConnectionVerifyError(err)
}
return nil
}
@@ -224,7 +224,7 @@ func (m *MariaDB) GetCreateStatement(dbName, tableName string) (string, error) {
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (m *MariaDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -438,7 +438,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
func (m *MariaDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if dbName == "" {
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
return nil, localizedDatabaseRuntimeError("db.backend.error.database_name_required", nil)
}
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''"))

View File

@@ -0,0 +1,115 @@
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
func TestMQTTTimeoutMessagesUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
cases := []struct {
name string
key string
want string
raw string
}{
{
name: "connect timeout",
key: "db.backend.error.mqtt_connect_timeout",
want: "MQTT connection timed out",
raw: "MQTT 连接超时",
},
{
name: "subscribe timeout",
key: "db.backend.error.mqtt_subscribe_timeout",
want: "MQTT subscription timed out",
raw: "MQTT 订阅超时",
},
{
name: "publish timeout",
key: "db.backend.error.mqtt_publish_timeout",
want: "MQTT publish timed out",
raw: "MQTT 发布超时",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := localizedDriverRuntimeText(tc.key, nil)
if got != tc.want {
t.Fatalf("expected %q, got %q", tc.want, got)
}
if strings.Contains(got, tc.raw) {
t.Fatalf("expected no raw Chinese timeout text, got %q", got)
}
})
}
}
func TestMQTTTimeoutSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("mqtt_impl.go")
if err != nil {
t.Fatalf("read mqtt_impl.go: %v", err)
}
source := string(sourceBytes)
checks := []struct {
signature string
rawText string
key string
}{
{
signature: "func newPahoMQTTRuntime(config connection.ConnectionConfig) (mqttRuntime, error)",
rawText: `fmt.Errorf("MQTT 连接超时")`,
key: "db.backend.error.mqtt_connect_timeout",
},
{
signature: "func (r *pahoMQTTRuntime) FetchMessages(ctx context.Context, request mqttFetchRequest) ([]mqttMessageRecord, error)",
rawText: `fmt.Errorf("MQTT 订阅超时")`,
key: "db.backend.error.mqtt_subscribe_timeout",
},
{
signature: "func (r *pahoMQTTRuntime) Publish(ctx context.Context, command mqttPublishCommand) (int64, error)",
rawText: `fmt.Errorf("MQTT 发布超时")`,
key: "db.backend.error.mqtt_publish_timeout",
},
}
for _, tc := range checks {
functionSource := databaseFunctionSource(t, source, tc.signature)
if strings.Contains(functionSource, tc.rawText) {
t.Fatalf("%s still contains raw MQTT timeout text %q", tc.signature, tc.rawText)
}
if !strings.Contains(functionSource, tc.key) {
t.Fatalf("%s does not reference i18n key %q", tc.signature, tc.key)
}
}
}
func TestMQTTTimeoutCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.mqtt_connect_timeout",
"db.backend.error.mqtt_subscribe_timeout",
"db.backend.error.mqtt_publish_timeout",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing MQTT timeout key %q", language, key)
}
}
}
}

View File

@@ -745,7 +745,7 @@ func newPahoMQTTRuntime(config connection.ConnectionConfig) (mqttRuntime, error)
client := pahomqtt.NewClient(options)
token := client.Connect()
if !token.WaitTimeout(timeout + 5*time.Second) {
return nil, fmt.Errorf("MQTT 连接超时")
return nil, localizedDatabaseRuntimeError("db.backend.error.mqtt_connect_timeout", nil)
}
if err := token.Error(); err != nil {
return nil, err
@@ -861,7 +861,7 @@ func (r *pahoMQTTRuntime) FetchMessages(ctx context.Context, request mqttFetchRe
token := r.client.Subscribe(request.Topic, request.QoS, callback)
if !token.WaitTimeout(r.timeout) {
return nil, fmt.Errorf("MQTT 订阅超时")
return nil, localizedDatabaseRuntimeError("db.backend.error.mqtt_subscribe_timeout", nil)
}
if err := token.Error(); err != nil {
return nil, fmt.Errorf("MQTT 订阅失败:%w", err)
@@ -920,7 +920,7 @@ func (r *pahoMQTTRuntime) Publish(ctx context.Context, command mqttPublishComman
}
}
if !token.WaitTimeout(wait) {
return 0, fmt.Errorf("MQTT 发布超时")
return 0, localizedDatabaseRuntimeError("db.backend.error.mqtt_publish_timeout", nil)
}
if err := token.Error(); err != nil {
return 0, err

View File

@@ -0,0 +1,151 @@
package db
import (
"database/sql"
"database/sql/driver"
"io"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/shared/i18n"
)
type mysqlI18nEmptyRowsDriver struct{}
type mysqlI18nEmptyRowsConn struct{}
type mysqlI18nEmptyRowsStmt struct{}
type mysqlI18nEmptyRowsRows struct{}
var registerMySQLI18nEmptyRowsDriverOnce sync.Once
var rawMySQLCreateStatementNotFoundText = string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5})
var rawMySQLAllColumnsDatabaseRequiredText = string([]rune{0x83b7, 0x53d6, 0x5168, 0x90e8, 0x5217, 0x4fe1, 0x606f, 0x9700, 0x8981, 0x6307, 0x5b9a, 0x6570, 0x636e, 0x5e93, 0x540d, 0x79f0})
func (mysqlI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return mysqlI18nEmptyRowsConn{}, nil
}
func (mysqlI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return mysqlI18nEmptyRowsStmt{}, nil
}
func (mysqlI18nEmptyRowsConn) Close() error { return nil }
func (mysqlI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_not_open", nil)
}
func (mysqlI18nEmptyRowsStmt) Close() error { return nil }
func (mysqlI18nEmptyRowsStmt) NumInput() int { return -1 }
func (mysqlI18nEmptyRowsStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.RowsAffected(0), nil
}
func (mysqlI18nEmptyRowsStmt) Query(args []driver.Value) (driver.Rows, error) {
return mysqlI18nEmptyRowsRows{}, nil
}
func (mysqlI18nEmptyRowsRows) Columns() []string {
return []string{"Create Table"}
}
func (mysqlI18nEmptyRowsRows) Close() error { return nil }
func (mysqlI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openMySQLI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerMySQLI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("mysql_i18n_empty_rows", mysqlI18nEmptyRowsDriver{})
})
conn, err := sql.Open("mysql_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open mysql_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func TestMySQLCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
mysqlDB := &MySQLDB{conn: openMySQLI18nEmptyRowsDB(t)}
_, err := mysqlDB.GetCreateStatement("app", "orders")
if err == nil {
t.Fatal("expected MySQL GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawMySQLCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
}
func TestMySQLCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("mysql_impl.go")
if err != nil {
t.Fatalf("read mysql_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawMySQLCreateStatementNotFoundText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("mysql_impl.go still contains raw create-statement text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("mysql_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
}
func TestMySQLGetAllColumnsDatabaseRequiredUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
mysqlDB := &MySQLDB{}
_, err := mysqlDB.GetAllColumns("")
if err == nil {
t.Fatal("expected MySQL GetAllColumns to fail")
}
if err.Error() != "Database name is required" {
t.Fatalf("expected English database-name error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawMySQLAllColumnsDatabaseRequiredText) {
t.Fatalf("expected no raw Chinese database-name text, got %q", err.Error())
}
}
func TestMySQLGetAllColumnsDatabaseRequiredSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("mysql_impl.go")
if err != nil {
t.Fatalf("read mysql_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawMySQLAllColumnsDatabaseRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("mysql_impl.go still contains raw database-name text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.database_name_required") {
t.Fatal("mysql_impl.go does not reference db.backend.error.database_name_required")
}
}

View File

@@ -1080,7 +1080,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -1241,7 +1241,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if err := requireSingleRowAffected(res, "删除"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionDelete); err != nil {
return err
}
}
@@ -1275,7 +1275,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if err := requireSingleRowAffected(res, "更新"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionUpdate); err != nil {
return err
}
}
@@ -1620,7 +1620,7 @@ func formatMySQLDateTime(t time.Time) string {
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if dbName == "" {
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
return nil, localizedDatabaseRuntimeError("db.backend.error.database_name_required", nil)
}
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", strings.ReplaceAll(dbName, "'", "''"))

View File

@@ -881,7 +881,10 @@ func (o *OceanBaseDB) GetCreateStatement(dbName, tableName string) (string, erro
return showDDL, nil
}
if err != nil {
return "", fmt.Errorf("%wOceanBase Oracle SHOW CREATE TABLE 兜底失败:%v", err, showErr)
return "", localizedDatabaseRuntimeError("db.backend.error.oceanbase_oracle_show_create_table_fallback_failed", map[string]any{
"metadataDetail": err.Error(),
"showDetail": showErr.Error(),
})
}
return "", showErr
}
@@ -906,7 +909,7 @@ func (o *OceanBaseDB) getOceanBaseOracleShowCreateStatement(dbName string, table
if firstErr != nil {
return "", firstErr
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func buildOceanBaseOracleShowCreateTableQuery(schema string, table string) string {
@@ -1100,7 +1103,7 @@ func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes conn
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if err := requireSingleRowAffected(res, "删除"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionDelete); err != nil {
return err
}
}
@@ -1131,7 +1134,7 @@ func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes conn
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if err := requireSingleRowAffected(res, "更新"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionUpdate); err != nil {
return err
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"net"
"net/url"
"os"
"slices"
"strconv"
"strings"
@@ -15,6 +16,7 @@ import (
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
mysqlDriver "github.com/go-sql-driver/mysql"
)
@@ -859,6 +861,52 @@ func TestOceanBaseOracleGetCreateStatementFallsBackToShowCreateTable(t *testing.
}
}
func TestOceanBaseOracleCreateStatementFallbackErrorUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
defer SetBackendLanguage(i18n.LanguageZhCN)
dbConn, _ := openOracleRecordingDB(t)
oceanbaseDB := &OceanBaseDB{}
oceanbaseDB.bindConnectedDatabase(dbConn, 0, oceanBaseProtocolOracle)
_, err := oceanbaseDB.GetCreateStatement("SYS", "test")
if err == nil {
t.Fatal("GetCreateStatement() expected error")
}
message := err.Error()
if strings.Contains(message, "未找到建表语句") || strings.Contains(message, "兜底失败") {
t.Fatalf("expected localized English fallback error, got %q", message)
}
if !strings.Contains(message, "The CREATE TABLE statement was not found") {
t.Fatalf("expected localized create-table-not-found detail, got %q", message)
}
if !strings.Contains(message, "OceanBase Oracle SHOW CREATE TABLE fallback failed") {
t.Fatalf("expected localized fallback wrapper, got %q", message)
}
}
func TestOceanBaseOracleCreateStatementSourceUsesI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("oceanbase_impl.go")
if err != nil {
t.Fatalf("read oceanbase_impl.go: %v", err)
}
source := string(sourceBytes)
if strings.Contains(source, `fmt.Errorf("未找到建表语句")`) {
t.Fatal("oceanbase_impl.go still contains raw create-statement-not-found error")
}
if strings.Contains(source, "OceanBase Oracle SHOW CREATE TABLE 兜底失败") {
t.Fatal("oceanbase_impl.go still contains raw OceanBase SHOW CREATE TABLE fallback wrapper")
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("oceanbase_impl.go does not reference db.backend.error.create_table_statement_not_found")
}
if !strings.Contains(source, "db.backend.error.oceanbase_oracle_show_create_table_fallback_failed") {
t.Fatal("oceanbase_impl.go does not reference OceanBase SHOW CREATE TABLE fallback i18n key")
}
}
// 用户通过 ConnectionParams 设置 connectionAttributes 时OceanBase MySQL wire 路径必须把
// 这些 attribute 透传到 go-sql-driver/mysql DSN让 driver 在握手响应里发 CLIENT_CONNECT_ATTRS。
// 这是 OBClient 协议握手探索的入口:高级用户/DBA 可以试错不同 attribute 组合而不需要改 GoNavi 代码。

View File

@@ -0,0 +1,234 @@
package db
import (
"database/sql"
"database/sql/driver"
"errors"
"io"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
type oracleI18nQueryErrorDriver struct{}
type oracleI18nQueryErrorConn struct{}
type oracleI18nEmptyRowsDriver struct{}
type oracleI18nEmptyRowsConn struct{}
type oracleI18nEmptyRowsRows struct{}
var registerOracleI18nQueryErrorDriverOnce sync.Once
var registerOracleI18nEmptyRowsDriverOnce sync.Once
var rawOracleCreateStatementNotFoundText = string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5})
func (oracleI18nQueryErrorDriver) Open(name string) (driver.Conn, error) {
return oracleI18nQueryErrorConn{}, nil
}
func (oracleI18nQueryErrorConn) Prepare(query string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported in oracle i18n query error test driver")
}
func (oracleI18nQueryErrorConn) Close() error { return nil }
func (oracleI18nQueryErrorConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported in oracle i18n query error test driver")
}
func (oracleI18nQueryErrorConn) Query(query string, args []driver.Value) (driver.Rows, error) {
return nil, errors.New("oracle metadata probe failed")
}
func (oracleI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return oracleI18nEmptyRowsConn{}, nil
}
func (oracleI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported in oracle i18n empty rows test driver")
}
func (oracleI18nEmptyRowsConn) Close() error { return nil }
func (oracleI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported in oracle i18n empty rows test driver")
}
func (oracleI18nEmptyRowsConn) Query(query string, args []driver.Value) (driver.Rows, error) {
return oracleI18nEmptyRowsRows{}, nil
}
func (oracleI18nEmptyRowsRows) Columns() []string {
return []string{"DDL"}
}
func (oracleI18nEmptyRowsRows) Close() error { return nil }
func (oracleI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openOracleI18nQueryErrorDB(t *testing.T) *sql.DB {
t.Helper()
registerOracleI18nQueryErrorDriverOnce.Do(func() {
sql.Register("oracle_i18n_query_error", oracleI18nQueryErrorDriver{})
})
conn, err := sql.Open("oracle_i18n_query_error", "")
if err != nil {
t.Fatalf("open oracle_i18n_query_error test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func openOracleI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerOracleI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("oracle_i18n_empty_rows", oracleI18nEmptyRowsDriver{})
})
conn, err := sql.Open("oracle_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open oracle_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func oracleFunctionSource(t *testing.T, source string, signature string) string {
t.Helper()
start := strings.Index(source, signature)
if start < 0 {
t.Fatalf("oracle_impl.go missing function signature %q", signature)
}
rest := source[start+len(signature):]
end := strings.Index(rest, "\nfunc ")
if end < 0 {
return source[start:]
}
return source[start : start+len(signature)+end]
}
func TestOracleCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
oracleDB := &OracleDB{conn: openOracleI18nEmptyRowsDB(t)}
_, err := oracleDB.GetCreateStatement("APP", "ORDERS")
if err == nil {
t.Fatal("expected Oracle GetCreateStatement to fail")
}
if err.Error() != "The CREATE TABLE statement was not found" {
t.Fatalf("expected English create-statement error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawOracleCreateStatementNotFoundText) {
t.Fatalf("expected no raw Chinese create-statement text, got %q", err.Error())
}
}
func TestOracleMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
t.Run("indexes table name required", func(t *testing.T) {
_, err := (&OracleDB{}).GetIndexes("", " ")
if err == nil {
t.Fatal("expected Oracle GetIndexes to reject an empty table name")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
})
t.Run("apply changes wraps oracle column metadata load failure", func(t *testing.T) {
oracleDB := &OracleDB{conn: openOracleI18nQueryErrorDB(t)}
err := oracleDB.ApplyChanges("APP.USERS", connection.ChangeSet{})
if err == nil {
t.Fatal("expected Oracle ApplyChanges to surface column metadata load failure")
}
want := "Failed to load column metadata (table=APP.USERS): oracle metadata probe failed; check ALL_TAB_COLUMNS query permission and whether the table exists"
if err.Error() != want {
t.Fatalf("expected English Oracle column-metadata error %q, got %q", want, err.Error())
}
if strings.Contains(err.Error(), "加载列元数据失败") {
t.Fatalf("expected no legacy Chinese Oracle column-metadata prefix in en-US mode, got %q", err.Error())
}
})
}
func TestOracleUserVisibleMetadataErrorsDoNotReintroduceInlineChinese(t *testing.T) {
sourceBytes, err := os.ReadFile("oracle_impl.go")
if err != nil {
t.Fatalf("read oracle_impl.go: %v", err)
}
source := string(sourceBytes)
getIndexesSource := oracleFunctionSource(t, source, "func (o *OracleDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error)")
if strings.Contains(getIndexesSource, `fmt.Errorf("表名不能为空")`) {
t.Fatal("GetIndexes still contains raw Chinese table-name-required text")
}
if !strings.Contains(getIndexesSource, "db.backend.error.table_name_required") {
t.Fatal("GetIndexes does not reference db.backend.error.table_name_required")
}
getCreateStatementSource := oracleFunctionSource(t, source, "func (o *OracleDB) GetCreateStatement(dbName, tableName string) (string, error)")
rawCreateStatementMessage := `fmt.Errorf("` + rawOracleCreateStatementNotFoundText + `")`
if strings.Contains(getCreateStatementSource, rawCreateStatementMessage) {
t.Fatalf("GetCreateStatement still contains raw create-statement text %q", rawCreateStatementMessage)
}
if !strings.Contains(getCreateStatementSource, "db.backend.error.create_table_statement_not_found") {
t.Fatal("GetCreateStatement does not reference db.backend.error.create_table_statement_not_found")
}
loadColumnTypeMapSource := oracleFunctionSource(t, source, "func (o *OracleDB) loadColumnTypeMap(tableName string) (map[string]string, error)")
if strings.Contains(loadColumnTypeMapSource, `fmt.Errorf("加载列元数据失败(表=%s%w请检查 ALL_TAB_COLUMNS 查询权限与表是否存在", tableName, err)`) {
t.Fatal("loadColumnTypeMap still contains raw Chinese Oracle column-metadata failure text")
}
if !strings.Contains(loadColumnTypeMapSource, "db.backend.error.oracle_column_metadata_load_failed") {
t.Fatal("loadColumnTypeMap does not reference db.backend.error.oracle_column_metadata_load_failed")
}
}
func TestOracleMetadataCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.create_table_statement_not_found",
"db.backend.error.table_name_required",
"db.backend.error.oracle_column_metadata_load_failed",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing Oracle metadata key %q", language, key)
}
}
}
}

View File

@@ -28,6 +28,10 @@ type OracleDB struct {
var _ SessionExecerProvider = (*OracleDB)(nil)
var _ TransactionExecerProvider = (*OracleDB)(nil)
func oracleRuntimeError(key string, params map[string]any) error {
return fmt.Errorf("%s", localizedDriverRuntimeText(key, params))
}
func (o *OracleDB) getDSN(config connection.ConnectionConfig) string {
// oracle://user:pass@host:port/service_name
database := strings.TrimSpace(config.Database)
@@ -357,7 +361,7 @@ func (o *OracleDB) GetCreateStatement(dbName, tableName string) (string, error)
if firstErr != nil {
return "", firstErr
}
return "", fmt.Errorf("未找到建表语句")
return "", oracleRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (o *OracleDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -756,7 +760,7 @@ func parseOracleColumns(data []map[string]interface{}) []connection.ColumnDefini
func (o *OracleDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
if strings.TrimSpace(tableName) == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, oracleRuntimeError("db.backend.error.table_name_required", nil)
}
for _, candidate := range oracleMetadataNamePairs(dbName, tableName) {
@@ -954,7 +958,10 @@ func (o *OracleDB) loadColumnTypeMap(tableName string) (map[string]string, error
columns, err := o.GetColumns(schema, table)
if err != nil {
return nil, fmt.Errorf("加载列元数据失败(表=%s%w请检查 ALL_TAB_COLUMNS 查询权限与表是否存在", tableName, err)
return nil, oracleRuntimeError("db.backend.error.oracle_column_metadata_load_failed", map[string]any{
"table": tableName,
"detail": err.Error(),
})
}
for _, col := range columns {
@@ -1122,7 +1129,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if err := requireSingleRowAffected(res, "删除"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionDelete); err != nil {
return err
}
}
@@ -1155,7 +1162,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if err := requireSingleRowAffected(res, "更新"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionUpdate); err != nil {
return err
}
}

View File

@@ -0,0 +1,84 @@
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawPostgresTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
func TestPostgresMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
postgres := &PostgresDB{}
tests := []struct {
name string
call func() error
}{
{
name: "columns table name required",
call: func() error {
_, err := postgres.GetColumns("", " ")
return err
},
},
{
name: "indexes table name required",
call: func() error {
_, err := postgres.GetIndexes("", " ")
return err
},
},
{
name: "foreign keys table name required",
call: func() error {
_, err := postgres.GetForeignKeys("", " ")
return err
},
},
{
name: "triggers table name required",
call: func() error {
_, err := postgres.GetTriggers("", " ")
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected PostgreSQL metadata call to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawPostgresTableNameRequiredText) {
t.Fatalf("expected no raw Chinese PostgreSQL metadata text, got %q", err.Error())
}
})
}
}
func TestPostgresMetadataErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("postgres_impl.go")
if err != nil {
t.Fatalf("read postgres_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawPostgresTableNameRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("postgres_impl.go still contains raw PostgreSQL metadata text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.table_name_required") {
t.Fatal("postgres_impl.go does not reference db.backend.error.table_name_required")
}
}

View File

@@ -82,7 +82,7 @@ func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := DriverRuntimeSupportStatus("postgres"); !supported {
if strings.TrimSpace(reason) == "" {
reason = "PostgreSQL 纯 Go 驱动未启用,请先在驱动管理中安装启用"
reason = localizedDriverRuntimeText("driver_manager.backend.status.optional_disabled", map[string]any{"name": "PostgreSQL"})
}
return fmt.Errorf("%s", reason)
}
@@ -408,7 +408,7 @@ func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := p.Query(buildPGLikeColumnsMetadataQuery(schema, table))
@@ -422,7 +422,7 @@ func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := p.Query(buildPGLikeIndexesMetadataQuery(schema, table))
@@ -440,7 +440,7 @@ func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -500,7 +500,7 @@ func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -719,7 +719,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if err := requireSingleRowAffected(res, "删除"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionDelete); err != nil {
return err
}
}
@@ -756,7 +756,7 @@ func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if err := requireSingleRowAffected(res, "更新"); err != nil {
if err := requireSingleRowAffected(res, rowMutationActionUpdate); err != nil {
return err
}
}

View File

@@ -0,0 +1,274 @@
//go:build gonavi_full_drivers || gonavi_sqlite_driver
package db
import (
"database/sql"
"database/sql/driver"
"io"
"os"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
type sqliteI18nEmptyRowsDriver struct{}
type sqliteI18nEmptyRowsConn struct{}
type sqliteI18nEmptyRowsStmt struct{}
type sqliteI18nEmptyRowsRows struct{}
var registerSQLiteI18nEmptyRowsDriverOnce sync.Once
var (
rawSQLiteCreateStatementNotFoundText = string([]rune{0x672a, 0x627e, 0x5230, 0x5efa, 0x8868, 0x8bed, 0x53e5})
rawSQLiteTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
rawSQLiteFilePathRequiredText = "SQLite " + string([]rune{0x9700, 0x8981, 0x672c, 0x5730, 0x6570, 0x636e, 0x5e93, 0x6587, 0x4ef6, 0x8def, 0x5f84})
rawSQLiteHostAddressHintText = string([]rune{0x5f53, 0x524d, 0x8f93, 0x5165, 0x770b, 0x8d77, 0x6765, 0x662f, 0x4e3b, 0x673a, 0x5730, 0x5740})
)
func (sqliteI18nEmptyRowsDriver) Open(name string) (driver.Conn, error) {
return sqliteI18nEmptyRowsConn{}, nil
}
func (sqliteI18nEmptyRowsConn) Prepare(query string) (driver.Stmt, error) {
return sqliteI18nEmptyRowsStmt{}, nil
}
func (sqliteI18nEmptyRowsConn) Close() error { return nil }
func (sqliteI18nEmptyRowsConn) Begin() (driver.Tx, error) {
return nil, localizedDatabaseRuntimeError("db.backend.error.transaction_not_open", nil)
}
func (sqliteI18nEmptyRowsStmt) Close() error { return nil }
func (sqliteI18nEmptyRowsStmt) NumInput() int { return -1 }
func (sqliteI18nEmptyRowsStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.RowsAffected(0), nil
}
func (sqliteI18nEmptyRowsStmt) Query(args []driver.Value) (driver.Rows, error) {
return sqliteI18nEmptyRowsRows{}, nil
}
func (sqliteI18nEmptyRowsRows) Columns() []string {
return []string{"sql"}
}
func (sqliteI18nEmptyRowsRows) Close() error { return nil }
func (sqliteI18nEmptyRowsRows) Next(dest []driver.Value) error {
return io.EOF
}
func openSQLiteI18nEmptyRowsDB(t *testing.T) *sql.DB {
t.Helper()
registerSQLiteI18nEmptyRowsDriverOnce.Do(func() {
sql.Register("sqlite_i18n_empty_rows", sqliteI18nEmptyRowsDriver{})
})
conn, err := sql.Open("sqlite_i18n_empty_rows", "")
if err != nil {
t.Fatalf("open sqlite_i18n_empty_rows test DB failed: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
})
return conn
}
func TestSQLiteMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
sqlite := &SQLiteDB{}
tests := []struct {
name string
call func() error
want string
unexpected string
}{
{
name: "create statement not found",
call: func() error {
_, err := (&SQLiteDB{conn: openSQLiteI18nEmptyRowsDB(t)}).GetCreateStatement("main", "orders")
return err
},
want: "The CREATE TABLE statement was not found",
unexpected: rawSQLiteCreateStatementNotFoundText,
},
{
name: "columns table name required",
call: func() error {
_, err := sqlite.GetColumns("", " ")
return err
},
want: "Table name is required",
unexpected: rawSQLiteTableNameRequiredText,
},
{
name: "indexes table name required",
call: func() error {
_, err := sqlite.GetIndexes("", " ")
return err
},
want: "Table name is required",
unexpected: rawSQLiteTableNameRequiredText,
},
{
name: "foreign keys table name required",
call: func() error {
_, err := sqlite.GetForeignKeys("", " ")
return err
},
want: "Table name is required",
unexpected: rawSQLiteTableNameRequiredText,
},
{
name: "triggers table name required",
call: func() error {
_, err := sqlite.GetTriggers("", " ")
return err
},
want: "Table name is required",
unexpected: rawSQLiteTableNameRequiredText,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected SQLite metadata call to fail")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
if strings.Contains(err.Error(), tc.unexpected) {
t.Fatalf("expected no raw Chinese SQLite metadata text, got %q", err.Error())
}
})
}
}
func TestSQLiteDSNValidationErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tests := []struct {
name string
config connection.ConnectionConfig
want string
unexpected string
}{
{
name: "empty path",
config: connection.ConnectionConfig{Type: "sqlite"},
want: "SQLite requires a local database file path (for example /path/to/demo.sqlite)",
unexpected: rawSQLiteFilePathRequiredText,
},
{
name: "host port",
config: connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"},
want: "SQLite requires a local database file path; the current input looks like a host address: localhost:3306",
unexpected: rawSQLiteHostAddressHintText,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := (&SQLiteDB{}).Connect(tc.config)
if err == nil {
t.Fatal("expected SQLite DSN validation error")
}
if err.Error() != tc.want {
t.Fatalf("expected %q, got %q", tc.want, err.Error())
}
if strings.Contains(err.Error(), tc.unexpected) {
t.Fatalf("expected no raw Chinese SQLite DSN validation text, got %q", err.Error())
}
})
}
}
func TestSQLiteMetadataErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("sqlite_impl.go")
if err != nil {
t.Fatalf("read sqlite_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
`fmt.Errorf("` + rawSQLiteCreateStatementNotFoundText + `")`,
`fmt.Errorf("` + rawSQLiteTableNameRequiredText + `")`,
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("sqlite_impl.go still contains raw SQLite metadata text %q", rawMessage)
}
}
for _, key := range []string{
"db.backend.error.create_table_statement_not_found",
"db.backend.error.table_name_required",
} {
if !strings.Contains(source, key) {
t.Fatalf("sqlite_impl.go does not reference i18n key %q", key)
}
}
}
func TestSQLiteDSNValidationErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("sqlite_impl.go")
if err != nil {
t.Fatalf("read sqlite_impl.go: %v", err)
}
source := string(sourceBytes)
for _, rawMessage := range []string{
rawSQLiteFilePathRequiredText,
rawSQLiteHostAddressHintText,
} {
if strings.Contains(source, rawMessage) {
t.Fatalf("sqlite_impl.go still contains raw SQLite DSN validation text %q", rawMessage)
}
}
for _, key := range []string{
"db.backend.error.sqlite_file_path_required",
"db.backend.error.sqlite_host_port_not_file_path",
} {
if !strings.Contains(source, key) {
t.Fatalf("sqlite_impl.go does not reference i18n key %q", key)
}
}
}
func TestSQLiteDSNValidationErrorCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
keys := []string{
"db.backend.error.sqlite_file_path_required",
"db.backend.error.sqlite_host_port_not_file_path",
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range keys {
if strings.TrimSpace(catalog[key]) == "" {
t.Fatalf("%s catalog missing SQLite DSN validation key %q", language, key)
}
}
}
}

View File

@@ -34,7 +34,7 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
return wrapDatabaseConnectionOpenError(err)
}
s.conn = db
s.pingTimeout = getConnectTimeout(config)
@@ -43,7 +43,7 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
if err := s.Ping(); err != nil {
_ = db.Close()
s.conn = nil
return fmt.Errorf("连接建立后验证失败:%w", err)
return wrapDatabaseConnectionVerifyError(err)
}
return nil
}
@@ -55,13 +55,13 @@ func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) {
}
dsn = normalizeSQLitePath(dsn)
if dsn == "" {
return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite")
return "", localizedDatabaseRuntimeError("db.backend.error.sqlite_file_path_required", nil)
}
if strings.EqualFold(dsn, ":memory:") {
return dsn, nil
}
if looksLikeHostPort(dsn) {
return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn)
return "", localizedDatabaseRuntimeError("db.backend.error.sqlite_host_port_not_file_path", map[string]any{"dsn": dsn})
}
return dsn, nil
}
@@ -297,13 +297,13 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
return fmt.Sprintf("%v", val), nil
}
}
return "", fmt.Errorf("未找到建表语句")
return "", localizedDatabaseRuntimeError("db.backend.error.create_table_statement_not_found", nil)
}
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -394,7 +394,7 @@ func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefi
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -485,7 +485,7 @@ func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefin
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }
@@ -559,7 +559,7 @@ func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.Foreig
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(v string) string { return strings.ReplaceAll(v, "'", "''") }

View File

@@ -174,13 +174,13 @@ func (s *SqlServerDB) Connect(config connection.ConnectionConfig) error {
db, err := sql.Open("sqlserver", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
return wrapDatabaseConnectionOpenError(err)
}
s.conn = db
s.pingTimeout = getConnectTimeout(config)
if err := s.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
return wrapDatabaseConnectionVerifyError(err)
}
return nil
}
@@ -500,7 +500,7 @@ func (s *SqlServerDB) GetColumns(dbName, tableName string) ([]connection.ColumnD
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -612,7 +612,7 @@ func (s *SqlServerDB) GetIndexes(dbName, tableName string) ([]connection.IndexDe
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -693,7 +693,7 @@ func (s *SqlServerDB) GetForeignKeys(dbName, tableName string) ([]connection.For
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -751,7 +751,7 @@ func (s *SqlServerDB) GetTriggers(dbName, tableName string) ([]connection.Trigge
}
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }

View File

@@ -4,9 +4,15 @@ package db
import (
"errors"
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawSQLServerTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
type fakeSQLServerExecResult struct {
affected int64
rowErr error
@@ -65,3 +71,76 @@ func TestSQLServerRowsAffectedDoesNotHideDMLRowsAffectedErrors(t *testing.T) {
t.Fatalf("expected rows affected error to propagate for DML, got %v", err)
}
}
func TestSQLServerMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
sqlServer := &SqlServerDB{}
tests := []struct {
name string
call func() error
}{
{
name: "columns table name required",
call: func() error {
_, err := sqlServer.GetColumns("", " ")
return err
},
},
{
name: "indexes table name required",
call: func() error {
_, err := sqlServer.GetIndexes("", " ")
return err
},
},
{
name: "foreign keys table name required",
call: func() error {
_, err := sqlServer.GetForeignKeys("", " ")
return err
},
},
{
name: "triggers table name required",
call: func() error {
_, err := sqlServer.GetTriggers("", " ")
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected SQL Server metadata call to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawSQLServerTableNameRequiredText) {
t.Fatalf("expected no raw Chinese SQL Server metadata text, got %q", err.Error())
}
})
}
}
func TestSQLServerMetadataErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("sqlserver_impl.go")
if err != nil {
t.Fatalf("read sqlserver_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawSQLServerTableNameRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("sqlserver_impl.go still contains raw SQL Server metadata text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.table_name_required") {
t.Fatal("sqlserver_impl.go does not reference db.backend.error.table_name_required")
}
}

View File

@@ -8,12 +8,14 @@ import (
"database/sql/driver"
"fmt"
"io"
"os"
"reflect"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
const tdengineRecordingDriverName = "gonavi_tdengine_recording"
@@ -220,6 +222,148 @@ func TestTDengineApplyChanges_RejectsMixedUpdatesWithoutPartialWrite(t *testing.
}
}
func rawTDengineConnectionNotOpenText() string {
return string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
}
func rawTDengineTableNameRequiredText() string {
return string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
}
func rawTDengineApplyChangesInsertOnlyText() string {
return string([]rune{
0x0054, 0x0044, 0x0065, 0x006e, 0x0067, 0x0069, 0x006e, 0x0065, 0x0020,
0x76ee, 0x6807, 0x7aef, 0x5f53, 0x524d, 0x4ec5, 0x652f, 0x6301,
0x0020, 0x0049, 0x004e, 0x0053, 0x0045, 0x0052, 0x0054, 0x0020,
0x5199, 0x5165, 0xff0c, 0x6682, 0x4e0d, 0x652f, 0x6301, 0x0020,
0x0055, 0x0050, 0x0044, 0x0041, 0x0054, 0x0045, 0x002f, 0x0044,
0x0045, 0x004c, 0x0045, 0x0054, 0x0045, 0x0020, 0x5dee, 0x5f02,
0x540c, 0x6b65, 0xff0c, 0x8bf7, 0x6539, 0x7528, 0x4ec5, 0x63d2,
0x5165, 0x6216, 0x5168, 0x91cf, 0x8986, 0x76d6, 0x6a21, 0x5f0f,
})
}
func tdengineApplyChangesI18nKeys() []string {
return []string{
"db.backend.error.connection_not_open",
"db.backend.error.table_name_required",
"db.backend.error.tdengine_apply_changes_insert_only",
}
}
func TestTDengineApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
t.Run("connection not open", func(t *testing.T) {
td := &TDengineDB{}
err := td.ApplyChanges("metrics", connection.ChangeSet{})
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawTDengineConnectionNotOpenText()) {
t.Fatalf("expected no raw Chinese connection-not-open text, got %q", err.Error())
}
})
t.Run("table name required", func(t *testing.T) {
dbConn, _ := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
err := td.ApplyChanges(" ", connection.ChangeSet{})
if err == nil {
t.Fatal("expected table-name-required error")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawTDengineTableNameRequiredText()) {
t.Fatalf("expected no raw Chinese table-name-required text, got %q", err.Error())
}
})
t.Run("update delete unsupported", func(t *testing.T) {
dbConn, state := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
changes := connection.ChangeSet{
Deletes: []map[string]interface{}{
{"ts": "2026-03-09 10:00:00"},
},
}
err := td.ApplyChanges("metrics", changes)
if err == nil {
t.Fatal("expected TDengine insert-only error")
}
want := "TDengine targets currently support only INSERT writes; UPDATE/DELETE differences are not supported by ApplyChanges"
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
if strings.Contains(err.Error(), rawTDengineApplyChangesInsertOnlyText()) {
t.Fatalf("expected no raw Chinese insert-only text, got %q", err.Error())
}
if queries := state.snapshotQueries(); len(queries) != 0 {
t.Fatalf("expected no SQL execution after insert-only rejection, got %#v", queries)
}
})
}
func TestTDengineApplyChangesErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("tdengine_impl.go")
if err != nil {
t.Fatalf("read tdengine_impl.go: %v", err)
}
source := string(sourceBytes)
start := strings.Index(source, "func (t *TDengineDB) ApplyChanges")
if start < 0 {
t.Fatal("TDengine ApplyChanges function not found")
}
end := strings.Index(source[start:], "func execTDengineInsertBatches")
if end < 0 {
t.Fatal("TDengine ApplyChanges function end marker not found")
}
applyChangesSource := source[start : start+end]
for _, rawMessage := range []string{
`fmt.Errorf("` + rawTDengineConnectionNotOpenText() + `")`,
`fmt.Errorf("` + rawTDengineTableNameRequiredText() + `")`,
`fmt.Errorf("` + rawTDengineApplyChangesInsertOnlyText() + `")`,
} {
if strings.Contains(applyChangesSource, rawMessage) {
t.Fatalf("TDengine ApplyChanges still contains raw text %q", rawMessage)
}
}
for _, key := range tdengineApplyChangesI18nKeys() {
if !strings.Contains(applyChangesSource, key) {
t.Fatalf("TDengine ApplyChanges does not reference i18n key %q", key)
}
}
}
func TestTDengineApplyChangesCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range tdengineApplyChangesI18nKeys() {
value := strings.TrimSpace(catalog[key])
if value == "" {
t.Fatalf("%s catalog missing TDengine ApplyChanges key %q", language, key)
}
if strings.Contains(value, "{{") || strings.Contains(value, "}}") {
t.Fatalf("%s catalog key %q should not use placeholders, got %q", language, key, value)
}
}
}
}
func TestTDengineGetTablesIncludesSuperTables(t *testing.T) {
t.Parallel()
@@ -373,3 +517,44 @@ func TestTDengineGetCreateStatementFallsBackToLegacySyntax(t *testing.T) {
t.Fatalf("unexpected query sequence: got=%v want=%v", queries, wantQueries)
}
}
func TestTDengineGetCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
dbConn, _ := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
_, err := td.GetCreateStatement("metrics", "meters")
if err == nil {
t.Fatal("expected CREATE TABLE not found error")
}
want := "The CREATE TABLE statement was not found"
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
rawNotFoundText := "\u672a\u627e\u5230\u5efa\u8868\u8bed\u53e5"
if strings.Contains(err.Error(), rawNotFoundText) {
t.Fatalf("expected no raw Chinese CREATE TABLE not found text, got %q", err.Error())
}
}
func TestTDengineGetCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("tdengine_impl.go")
if err != nil {
t.Fatalf("read tdengine_impl.go: %v", err)
}
source := string(sourceBytes)
rawNotFoundText := "\u672a\u627e\u5230\u5efa\u8868\u8bed\u53e5"
rawNotFoundSnippet := `fmt.Errorf("` + rawNotFoundText + `")`
if strings.Contains(source, rawNotFoundSnippet) {
t.Fatalf("TDengine GetCreateStatement still contains raw CREATE TABLE not found text")
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("TDengine GetCreateStatement does not reference db.backend.error.create_table_statement_not_found")
}
}

View File

@@ -0,0 +1,49 @@
//go:build gonavi_full_drivers || gonavi_tdengine_driver
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawTDengineAllColumnsDatabaseRequiredText = string([]rune{0x83b7, 0x53d6, 0x5168, 0x90e8, 0x5217, 0x4fe1, 0x606f, 0x9700, 0x8981, 0x6307, 0x5b9a, 0x6570, 0x636e, 0x5e93, 0x540d, 0x79f0})
func TestTDengineGetAllColumnsDatabaseRequiredUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
tdengineDB := &TDengineDB{}
_, err := tdengineDB.GetAllColumns(" ")
if err == nil {
t.Fatal("expected TDengine GetAllColumns to fail")
}
if err.Error() != "Database name is required" {
t.Fatalf("expected English database-name error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawTDengineAllColumnsDatabaseRequiredText) {
t.Fatalf("expected no raw Chinese database-name text, got %q", err.Error())
}
}
func TestTDengineGetAllColumnsDatabaseRequiredSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("tdengine_impl.go")
if err != nil {
t.Fatalf("read tdengine_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawTDengineAllColumnsDatabaseRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("tdengine_impl.go still contains raw database-name text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.database_name_required") {
t.Fatal("tdengine_impl.go does not reference db.backend.error.database_name_required")
}
}

View File

@@ -295,7 +295,7 @@ func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("未找到建表语句")
return "", errors.New(localizedDriverRuntimeText("db.backend.error.create_table_statement_not_found", nil))
}
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
@@ -362,7 +362,7 @@ func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if strings.TrimSpace(dbName) == "" {
return nil, fmt.Errorf("获取全部列信息需要指定数据库名称")
return nil, localizedDatabaseRuntimeError("db.backend.error.database_name_required", nil)
}
tables, err := t.GetTables(dbName)
@@ -403,13 +403,13 @@ func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if t.conn == nil {
return fmt.Errorf("连接未打开")
return localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
if strings.TrimSpace(tableName) == "" {
return fmt.Errorf("表名不能为空")
return localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
if len(changes.Updates) > 0 || len(changes.Deletes) > 0 {
return fmt.Errorf("TDengine 目标端当前仅支持 INSERT 写入,暂不支持 UPDATE/DELETE 差异同步,请改用仅插入或全量覆盖模式")
return localizedDatabaseRuntimeError("db.backend.error.tdengine_apply_changes_insert_only", nil)
}
qualifiedTable := quoteTDengineTable("", tableName)

View File

@@ -0,0 +1,86 @@
//go:build gonavi_full_drivers || gonavi_vastbase_driver
package db
import (
"os"
"strings"
"testing"
"GoNavi-Wails/shared/i18n"
)
var rawVastbaseTableNameRequiredText = string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
func TestVastbaseMetadataErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
vastbase := &VastbaseDB{}
tests := []struct {
name string
call func() error
}{
{
name: "columns table name required",
call: func() error {
_, err := vastbase.GetColumns("", " ")
return err
},
},
{
name: "indexes table name required",
call: func() error {
_, err := vastbase.GetIndexes("", " ")
return err
},
},
{
name: "foreign keys table name required",
call: func() error {
_, err := vastbase.GetForeignKeys("", " ")
return err
},
},
{
name: "triggers table name required",
call: func() error {
_, err := vastbase.GetTriggers("", " ")
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
if err == nil {
t.Fatal("expected Vastbase metadata call to fail")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawVastbaseTableNameRequiredText) {
t.Fatalf("expected no raw Chinese Vastbase metadata text, got %q", err.Error())
}
})
}
}
func TestVastbaseMetadataErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("vastbase_impl.go")
if err != nil {
t.Fatalf("read vastbase_impl.go: %v", err)
}
source := string(sourceBytes)
rawMessage := `fmt.Errorf("` + rawVastbaseTableNameRequiredText + `")`
if strings.Contains(source, rawMessage) {
t.Fatalf("vastbase_impl.go still contains raw Vastbase metadata text %q", rawMessage)
}
if !strings.Contains(source, "db.backend.error.table_name_required") {
t.Fatal("vastbase_impl.go does not reference db.backend.error.table_name_required")
}
}

View File

@@ -251,7 +251,7 @@ func (v *VastbaseDB) GetCreateStatement(dbName, tableName string) (string, error
func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := v.Query(buildPGLikeColumnsMetadataQuery(schema, table))
@@ -265,7 +265,7 @@ func (v *VastbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
func (v *VastbaseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
schema, table := normalizePGLikeMetadataTable(dbName, tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
data, _, err := v.Query(buildPGLikeIndexesMetadataQuery(schema, table))
@@ -283,7 +283,7 @@ func (v *VastbaseDB) GetForeignKeys(dbName, tableName string) ([]connection.Fore
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
@@ -343,7 +343,7 @@ func (v *VastbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
}
table := strings.TrimSpace(tableName)
if table == "" {
return nil, fmt.Errorf("表名不能为空")
return nil, localizedDatabaseRuntimeError("db.backend.error.table_name_required", nil)
}
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff