mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 13:11:24 +08:00
✨ feat(i18n): 收口数据库驱动多语言代码
- 提交 internal/db 多驱动用户可见错误与状态文案多语言化 - 补齐数据库驱动多语言测试与六语言 catalog - 修复 frontend i18n catalog 的 4 个失效 guard
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/9440,HTTP 常见端口 %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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
116
internal/db/dameng_i18n_test.go
Normal file
116
internal/db/dameng_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
677
internal/db/database_i18n_test.go
Normal file
677
internal/db/database_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
56
internal/db/driver_i18n.go
Normal file
56
internal/db/driver_i18n.go
Normal 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)
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
290
internal/db/duckdb_i18n_test.go
Normal file
290
internal/db/duckdb_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
127
internal/db/elasticsearch_i18n_test.go
Normal file
127
internal/db/elasticsearch_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
86
internal/db/highgo_i18n_test.go
Normal file
86
internal/db/highgo_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "''") }
|
||||
|
||||
74
internal/db/iris_i18n_test.go
Normal file
74
internal/db/iris_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
86
internal/db/kingbase_i18n_test.go
Normal file
86
internal/db/kingbase_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 := ""
|
||||
|
||||
153
internal/db/mariadb_i18n_test.go
Normal file
153
internal/db/mariadb_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "''"))
|
||||
|
||||
|
||||
115
internal/db/mqtt_i18n_test.go
Normal file
115
internal/db/mqtt_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
151
internal/db/mysql_i18n_test.go
Normal file
151
internal/db/mysql_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "''"))
|
||||
|
||||
|
||||
@@ -881,7 +881,10 @@ func (o *OceanBaseDB) GetCreateStatement(dbName, tableName string) (string, erro
|
||||
return showDDL, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w;OceanBase 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 代码。
|
||||
|
||||
234
internal/db/oracle_i18n_test.go
Normal file
234
internal/db/oracle_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
84
internal/db/postgres_i18n_test.go
Normal file
84
internal/db/postgres_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
274
internal/db/sqlite_i18n_test.go
Normal file
274
internal/db/sqlite_i18n_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "''") }
|
||||
|
||||
@@ -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, "'", "''") }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
49
internal/db/tdengine_i18n_test.go
Normal file
49
internal/db/tdengine_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
86
internal/db/vastbase_i18n_test.go
Normal file
86
internal/db/vastbase_i18n_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user