mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 02:51:35 +08:00
🐛 fix(oceanbase): 解决 Oracle 租户 MySQL wire 下双引号被误解析与列元数据静默失败
- DSN 注入 sql_mode='ANSI_QUOTES':让元数据查询的 AS "OWNER" 与 ApplyChanges 的 "schema"."table" 在 MySQL wire 上被识别为标识符 - sql_mode 加入 mysql driver 参数白名单,避免被 mergeMySQLConnectionParam 过滤丢弃 - 加载 Oracle 列元数据失败不再静默,改为返回带 ALL_TAB_COLUMNS 诊断提示的明确错误 - 修复 stripOceanBaseConnectionParamsForCache 未剥离 # 片段导致与 resolveOceanBaseProtocolParam 行为不一致 - 锁定 mysql ParseDSN 对 sys@oracle001#cluster:p@ss 类租户凭据切分的 invariant,防止未来误加 url.QueryEscape - 同步 OceanBase agent revision,强制旧 driver-agent 被运行时校验拒绝
This commit is contained in:
@@ -86,6 +86,12 @@ func stripOceanBaseConnectionParamsForCache(raw string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if queryIndex := strings.Index(text, "?"); queryIndex >= 0 {
|
||||
text = text[queryIndex+1:]
|
||||
}
|
||||
if hashIndex := strings.Index(text, "#"); hashIndex >= 0 {
|
||||
text = text[:hashIndex]
|
||||
}
|
||||
values, err := url.ParseQuery(strings.TrimLeft(text, "?&"))
|
||||
if err != nil {
|
||||
return text
|
||||
|
||||
33
internal/app/oceanbase_protocol_test.go
Normal file
33
internal/app/oceanbase_protocol_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripOceanBaseConnectionParamsForCacheTrimsFragment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := stripOceanBaseConnectionParamsForCache("protocol=oracle&PREFETCH_ROWS=5000#dev-note")
|
||||
if strings.Contains(got, "dev-note") {
|
||||
t.Fatalf("expected fragment removed, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "PREFETCH_ROWS=5000") {
|
||||
t.Fatalf("expected business param kept, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "protocol=") {
|
||||
t.Fatalf("expected protocol param stripped, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripOceanBaseConnectionParamsForCacheTrimsLeadingQuestionMark(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := stripOceanBaseConnectionParamsForCache("?protocol=oracle&timeout=10")
|
||||
if strings.Contains(got, "protocol=") {
|
||||
t.Fatalf("expected protocol param stripped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "timeout=10") {
|
||||
t.Fatalf("expected timeout kept, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package db
|
||||
func init() {
|
||||
optionalDriverAgentRevisions = map[string]string{
|
||||
"mariadb": "src-1a1cc64f8f92d92b",
|
||||
"oceanbase": "src-f6f19676bb5102d1",
|
||||
"oceanbase": "src-b10df5902bf60a23",
|
||||
"diros": "src-bcc78fa43671ade5",
|
||||
"sphinx": "src-404765c2fda68c5f",
|
||||
"sqlserver": "src-d9fba1eca0a27c49",
|
||||
|
||||
@@ -120,6 +120,7 @@ var mysqlSupportedDriverParamNames = map[string]string{
|
||||
"readtimeout": "readTimeout",
|
||||
"rejectreadonly": "rejectReadOnly",
|
||||
"serverpubkey": "serverPubKey",
|
||||
"sql_mode": "sql_mode",
|
||||
"timetruncate": "timeTruncate",
|
||||
"timeout": "timeout",
|
||||
"tls": "tls",
|
||||
|
||||
@@ -249,6 +249,28 @@ func withoutOceanBaseProtocolParams(config connection.ConnectionConfig) connecti
|
||||
return next
|
||||
}
|
||||
|
||||
// ensureOceanBaseOracleANSIQuotes 在 ConnectionParams 中注入 sql_mode='ANSI_QUOTES',
|
||||
// 让 OceanBase Oracle 租户通过 MySQL wire 连接时,把双引号当作标识符引用(Oracle 语义),
|
||||
// 否则元数据查询的列别名 `AS "OWNER"` 和 ApplyChanges 的 `"schema"."table"` 会被当作字符串字面量。
|
||||
// 用户已显式设置 sql_mode 时,追加 ANSI_QUOTES,保留其它 mode。
|
||||
func ensureOceanBaseOracleANSIQuotes(raw string) string {
|
||||
values := connectionParamsFromText(raw)
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
existing := strings.TrimSpace(values.Get("sql_mode"))
|
||||
if existing == "" {
|
||||
values.Set("sql_mode", "'ANSI_QUOTES'")
|
||||
return values.Encode()
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(existing), "ANSI_QUOTES") {
|
||||
return values.Encode()
|
||||
}
|
||||
trimmed := strings.Trim(existing, "'")
|
||||
values.Set("sql_mode", "'"+trimmed+",ANSI_QUOTES'")
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func isOceanBaseOracleTenantMySQLDriverError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -314,6 +336,9 @@ func (o *OceanBaseDB) Connect(config connection.ConnectionConfig) error {
|
||||
candidateConfig.Host = host
|
||||
candidateConfig.Port = port
|
||||
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(runConfig, index)
|
||||
if protocol == oceanBaseProtocolOracle {
|
||||
candidateConfig.ConnectionParams = ensureOceanBaseOracleANSIQuotes(candidateConfig.ConnectionParams)
|
||||
}
|
||||
|
||||
dsn, err := o.getDSN(candidateConfig)
|
||||
if err != nil {
|
||||
@@ -468,7 +493,10 @@ func (o *OceanBaseDB) applyOracleChangesMySQLWire(tableName string, changes conn
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
columnTypeMap := o.oracle.loadColumnTypeMap(tableName)
|
||||
columnTypeMap, err := o.oracle.loadColumnTypeMap(tableName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OceanBase Oracle 租户 %w", err)
|
||||
}
|
||||
|
||||
tx, err := o.oracle.conn.Begin()
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestResolveOceanBaseProtocol(t *testing.T) {
|
||||
@@ -214,6 +216,156 @@ func TestOceanBaseOracleApplyChangesUsesMySQLWirePlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OceanBase Oracle 租户用户名形如 SYS@oracle001#cluster_name,密码也可能含 @ 等保留字符。
|
||||
// 锁定 mysql driver ParseDSN 能正确切分 user/password,避免未来重构 buildMySQLCompatibleDSN 时
|
||||
// 误引入 url.QueryEscape 等会破坏认证的"修复"。
|
||||
func TestOceanBaseOracleDSNParsesTenantCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config connection.ConnectionConfig
|
||||
wantUser string
|
||||
wantPass string
|
||||
}{
|
||||
{
|
||||
name: "tenant user with @",
|
||||
config: connection.ConnectionConfig{
|
||||
Host: "127.0.0.1", Port: 2881,
|
||||
User: "sys@oracle001", Password: "pass", Database: "ORCL",
|
||||
},
|
||||
wantUser: "sys@oracle001",
|
||||
wantPass: "pass",
|
||||
},
|
||||
{
|
||||
name: "tenant user with @ and #cluster + password with @",
|
||||
config: connection.ConnectionConfig{
|
||||
Host: "127.0.0.1", Port: 2881,
|
||||
User: "sys@oracle001#cluster", Password: "p@ss", Database: "ORCL",
|
||||
},
|
||||
wantUser: "sys@oracle001#cluster",
|
||||
wantPass: "p@ss",
|
||||
},
|
||||
}
|
||||
|
||||
ob := &OceanBaseDB{}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dsn, err := ob.getDSN(tt.config)
|
||||
if err != nil {
|
||||
t.Fatalf("getDSN error: %v", err)
|
||||
}
|
||||
cfg, err := mysqlDriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("mysql ParseDSN failed for %q: %v", dsn, err)
|
||||
}
|
||||
if cfg.User != tt.wantUser {
|
||||
t.Fatalf("user mismatch: got %q want %q (dsn=%q)", cfg.User, tt.wantUser, dsn)
|
||||
}
|
||||
if cfg.Passwd != tt.wantPass {
|
||||
t.Fatalf("password mismatch: got %q want %q (dsn=%q)", cfg.Passwd, tt.wantPass, dsn)
|
||||
}
|
||||
if cfg.DBName != tt.config.Database {
|
||||
t.Fatalf("database mismatch: got %q want %q", cfg.DBName, tt.config.Database)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOceanBaseOracleANSIQuotesInjectsSqlMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "empty params",
|
||||
input: "",
|
||||
expect: "sql_mode=%27ANSI_QUOTES%27",
|
||||
},
|
||||
{
|
||||
name: "existing params without sql_mode",
|
||||
input: "PREFETCH_ROWS=5000",
|
||||
expect: "sql_mode=%27ANSI_QUOTES%27",
|
||||
},
|
||||
{
|
||||
name: "preserve user sql_mode and append ANSI_QUOTES",
|
||||
input: "sql_mode='STRICT_TRANS_TABLES'",
|
||||
expect: "sql_mode=%27STRICT_TRANS_TABLES%2CANSI_QUOTES%27",
|
||||
},
|
||||
{
|
||||
name: "no-op when user already includes ANSI_QUOTES",
|
||||
input: "sql_mode='ANSI_QUOTES,NO_AUTO_VALUE_ON_ZERO'",
|
||||
expect: "sql_mode=%27ANSI_QUOTES%2CNO_AUTO_VALUE_ON_ZERO%27",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ensureOceanBaseOracleANSIQuotes(tt.input)
|
||||
if !strings.Contains(got, tt.expect) {
|
||||
t.Fatalf("ensureOceanBaseOracleANSIQuotes(%q) = %q, want substring %q", tt.input, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOceanBaseOracleDSNContainsANSIQuotesSysVar(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "oceanbase",
|
||||
Host: "127.0.0.1",
|
||||
Port: 2881,
|
||||
User: "SYS@oracle001#cluster",
|
||||
Password: "p@ss",
|
||||
Database: "ORCL",
|
||||
OceanBaseProtocol: "oracle",
|
||||
}
|
||||
cfg.ConnectionParams = ensureOceanBaseOracleANSIQuotes(cfg.ConnectionParams)
|
||||
ob := &OceanBaseDB{}
|
||||
dsn, err := ob.getDSN(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("getDSN error: %v", err)
|
||||
}
|
||||
if !strings.Contains(dsn, "sql_mode=%27ANSI_QUOTES%27") {
|
||||
t.Fatalf("expected DSN to carry sql_mode='ANSI_QUOTES', got %q", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOceanBaseOracleApplyChangesFailsLoudOnColumnMetadataError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbConn, state := openOracleRecordingDB(t)
|
||||
state.queryError = errors.New("ORA-00942: table or view does not exist")
|
||||
|
||||
oceanbaseDB := &OceanBaseDB{}
|
||||
oceanbaseDB.bindConnectedDatabase(dbConn, 0, oceanBaseProtocolOracle)
|
||||
|
||||
changes := connection.ChangeSet{
|
||||
Updates: []connection.UpdateRow{{
|
||||
Keys: map[string]interface{}{"ID": 7},
|
||||
Values: map[string]interface{}{"NAME": "x"},
|
||||
}},
|
||||
}
|
||||
|
||||
err := oceanbaseDB.ApplyChanges("APP.USERS", changes)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when column metadata load fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "加载列元数据失败") {
|
||||
t.Fatalf("expected error message to mention column metadata, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ORA-00942") {
|
||||
t.Fatalf("expected error to wrap underlying ORA-00942, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOceanBaseMySQLAttemptErrorHintsOracleProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type oracleRecordingState struct {
|
||||
execArgs [][]driver.NamedValue
|
||||
rowsAffected int64
|
||||
queryResults map[string]oracleRecordingQueryResult
|
||||
queryError error
|
||||
}
|
||||
|
||||
type oracleRecordingQueryResult struct {
|
||||
@@ -87,6 +88,10 @@ func (c *oracleRecordingConn) ExecContext(_ context.Context, query string, args
|
||||
|
||||
func (c *oracleRecordingConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||
c.state.mu.Lock()
|
||||
if err := c.state.queryError; err != nil {
|
||||
c.state.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if result, ok := c.state.queryResults[query]; ok {
|
||||
c.state.mu.Unlock()
|
||||
return &oracleRecordingRows{
|
||||
|
||||
@@ -515,17 +515,16 @@ func splitOracleQualifiedTableName(raw string) (string, string) {
|
||||
return schema, table
|
||||
}
|
||||
|
||||
func (o *OracleDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
func (o *OracleDB) loadColumnTypeMap(tableName string) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
schema, table := splitOracleQualifiedTableName(tableName)
|
||||
if table == "" {
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
columns, err := o.GetColumns(schema, table)
|
||||
if err != nil {
|
||||
logger.Warnf("加载 Oracle 列元数据失败(不影响提交):表=%s err=%v", tableName, err)
|
||||
return result
|
||||
return nil, fmt.Errorf("加载列元数据失败(表=%s):%w;请检查 ALL_TAB_COLUMNS 查询权限与表是否存在", tableName, err)
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
@@ -535,7 +534,7 @@ func (o *OracleDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
}
|
||||
result[name] = strings.TrimSpace(col.Type)
|
||||
}
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeOracleValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
|
||||
@@ -614,7 +613,10 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
|
||||
return fmt.Errorf("连接未打开")
|
||||
}
|
||||
|
||||
columnTypeMap := o.loadColumnTypeMap(tableName)
|
||||
columnTypeMap, err := o.loadColumnTypeMap(tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := o.conn.Begin()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user