feat: Oracle 驱动完整化——回调体系、版本感知、驱动抽象层与测试套件

- 新增驱动抽象层 driver_adapter(go-ora/godror 双驱动切换)
- 新增 Update/Delete/Query 回调,完善 RETURNING INTO 子句
- 修复 create.go 事务悬挂行 BUG、批量 RowsAffected、主键 WHERE 注入
- 版本感知体系:分页/自增/序列默认值/BOOLEAN/32k VARCHAR2 分级能力判定
- 11g 序列+触发器自增、ON UPDATE 触发器、默认值智能转换
- 升级 go-ora v2.8.19 → v2.9.0
- 单元测试+模块测试+集成测试共 96 个,真实 Oracle 11g 全绿
- 测试 DSN 密码移除,改为 ORACLE_DSN 环境变量注入
This commit is contained in:
2026-08-08 10:36:57 +08:00
parent 1f6c8a18a3
commit 9154afab6b
35 changed files with 4956 additions and 37 deletions
+58
View File
@@ -0,0 +1,58 @@
package clauses
import (
"strconv"
"testing"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// testDialector 是仅用于 SQL 生成测试的最小 Dialector 实现,无需数据库连接。
// 它模拟 Oracle 的绑定变量占位符(:N)和不加引号的标识符引用。
type testDialector struct{}
func (testDialector) Name() string { return "oracle" }
func (testDialector) Initialize(*gorm.DB) error { return nil }
func (testDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
func (testDialector) DataTypeOf(*schema.Field) string { return "" }
func (testDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
func (testDialector) BindVarTo(writer clause.Writer, stmt *gorm.Statement, v interface{}) {
writer.WriteString(":")
writer.WriteString(strconv.Itoa(len(stmt.Vars)))
}
func (testDialector) QuoteTo(writer clause.Writer, str string) {
writer.WriteString(str)
}
func (testDialector) Explain(sql string, vars ...interface{}) string { return sql }
// newStatement 构造一个可以直接作为 clause.Builder 使用的 gorm.Statement。
func newStatement(t *testing.T) *gorm.Statement {
t.Helper()
db := &gorm.DB{
Config: &gorm.Config{Dialector: testDialector{}},
}
return &gorm.Statement{DB: db, Table: "users"}
}
// buildSQL 直接调用子句的 Build 方法生成 SQL。
func buildSQL(t *testing.T, expr clause.Expression) string {
t.Helper()
stmt := newStatement(t)
expr.Build(stmt)
return stmt.SQL.String()
}
// buildClauseSQL 通过 clause.Clause(模拟 gorm.Statement.Build 的构建流程)
// 生成带子句名前缀的完整 SQL。
func buildClauseSQL(t *testing.T, name string, expr clause.Expression) string {
t.Helper()
stmt := newStatement(t)
cc := clause.Clause{Name: name, Expression: expr}
cc.Build(stmt)
return stmt.SQL.String()
}
+78
View File
@@ -0,0 +1,78 @@
package clauses
import (
"reflect"
"strings"
"testing"
"gorm.io/gorm/clause"
)
func TestMergeName(t *testing.T) {
var m Merge
if got := m.Name(); got != "MERGE" {
t.Errorf("Merge.Name() = %q, want %q", got, "MERGE")
}
}
func TestMergeDefaultExcludeName(t *testing.T) {
if got := MergeDefaultExcludeName(); got != "exclude" {
t.Errorf("MergeDefaultExcludeName() = %q, want %q", got, "exclude")
}
}
func TestMergeBuild(t *testing.T) {
merge := Merge{
Table: clause.Table{Name: "users"},
Using: []clause.Interface{
clause.Select{Columns: []clause.Column{{Name: "id"}, {Name: "name"}}},
clause.From{Tables: []clause.Table{{Name: "users"}}},
},
On: []clause.Expression{
clause.Eq{Column: clause.Column{Name: "a"}, Value: clause.Column{Name: "b"}},
},
}
sql := buildClauseSQL(t, "MERGE", merge)
for _, want := range []string{
"MERGE INTO", // 前缀 + Insert 构建
"USING (", // USING 子查询
"SELECT id,name FROM users", // USING 子查询内容
") exclude ON (", // exclude 别名 + ON
"a = b", // ON 条件
} {
if !strings.Contains(sql, want) {
t.Errorf("Merge SQL %q does not contain %q", sql, want)
}
}
}
func TestMergeBuildEmpty(t *testing.T) {
var merge Merge
sql := buildClauseSQL(t, "MERGE", merge)
if want := "MERGE INTO users USING () exclude ON ()"; sql != want {
t.Errorf("empty Merge SQL = %q, want %q", sql, want)
}
}
func TestMergeMergeClause(t *testing.T) {
merge := Merge{
Table: clause.Table{Name: "users"},
On: []clause.Expression{
clause.Eq{Column: clause.Column{Name: "a"}, Value: clause.Column{Name: "b"}},
},
}
cc := &clause.Clause{}
merge.MergeClause(cc)
if cc.Name != "MERGE" {
t.Errorf("MergeClause name = %q, want %q", cc.Name, "MERGE")
}
if got, ok := cc.Expression.(Merge); !ok || !reflect.DeepEqual(got, merge) {
t.Errorf("MergeClause expression = %#v, want %#v", cc.Expression, merge)
}
}
+34
View File
@@ -1,6 +1,7 @@
package clauses
import (
"fmt"
"gorm.io/gorm/clause"
)
@@ -8,3 +9,36 @@ type ReturningInto struct {
Variables []clause.Column
Into []*clause.Values
}
// Name returns the name of the clause
func (r ReturningInto) Name() string {
return "RETURNING INTO"
}
// Build builds the SQL for the RETURNING INTO clause
func (r ReturningInto) Build(builder clause.Builder) {
if len(r.Variables) > 0 {
builder.WriteString("RETURNING ")
for idx, col := range r.Variables {
if idx > 0 {
builder.WriteByte(',')
}
builder.WriteQuoted(col)
}
builder.WriteString(" INTO ")
for idx := range r.Variables {
if idx > 0 {
builder.WriteByte(',')
}
// 写入绑定变量占位符
builder.WriteString(fmt.Sprintf(":%d", idx+1))
}
}
}
// MergeClause merge returning into clause
func (r ReturningInto) MergeClause(clause *clause.Clause) {
clause.Name = r.Name()
clause.Expression = r
}
+63
View File
@@ -0,0 +1,63 @@
package clauses
import (
"reflect"
"testing"
"gorm.io/gorm/clause"
)
func TestReturningIntoName(t *testing.T) {
var r ReturningInto
if got := r.Name(); got != "RETURNING INTO" {
t.Errorf("ReturningInto.Name() = %q, want %q", got, "RETURNING INTO")
}
}
func TestReturningIntoBuild(t *testing.T) {
r := ReturningInto{
Variables: []clause.Column{{Name: "col1"}, {Name: "col2"}},
}
sql := buildSQL(t, r)
if want := "RETURNING col1,col2 INTO :1,:2"; sql != want {
t.Errorf("ReturningInto SQL = %q, want %q", sql, want)
}
}
func TestReturningIntoBuildSingle(t *testing.T) {
r := ReturningInto{
Variables: []clause.Column{{Name: "col1"}},
}
sql := buildSQL(t, r)
if want := "RETURNING col1 INTO :1"; sql != want {
t.Errorf("ReturningInto SQL = %q, want %q", sql, want)
}
}
func TestReturningIntoBuildEmpty(t *testing.T) {
var r ReturningInto
sql := buildSQL(t, r)
if sql != "" {
t.Errorf("ReturningInto with empty Variables should generate no SQL, got %q", sql)
}
}
func TestReturningIntoMergeClause(t *testing.T) {
r := ReturningInto{
Variables: []clause.Column{{Name: "id"}},
}
cc := &clause.Clause{}
r.MergeClause(cc)
if cc.Name != "RETURNING INTO" {
t.Errorf("ReturningInto.MergeClause name = %q, want %q", cc.Name, "RETURNING INTO")
}
if got, ok := cc.Expression.(ReturningInto); !ok || !reflect.DeepEqual(got, r) {
t.Errorf("ReturningInto.MergeClause expression = %#v, want %#v", cc.Expression, r)
}
}
+2 -1
View File
@@ -19,7 +19,8 @@ func (w WhenMatched) Build(builder clause.Builder) {
builder.WriteString(" UPDATE ")
builder.WriteString(w.Name())
builder.WriteByte(' ')
w.Build(builder)
builder.WriteString("SET ")
w.Set.Build(builder)
buildWhere := func(where clause.Where) {
builder.WriteString(where.Name())
+100
View File
@@ -0,0 +1,100 @@
package clauses
import (
"strings"
"testing"
"gorm.io/gorm/clause"
)
func TestWhenMatchedName(t *testing.T) {
var w WhenMatched
if got := w.Name(); got != "WHEN MATCHED" {
t.Errorf("WhenMatched.Name() = %q, want %q", got, "WHEN MATCHED")
}
}
func TestWhenMatchedBuild(t *testing.T) {
w := WhenMatched{
Set: clause.Set{{Column: clause.Column{Name: "name"}, Value: "x"}},
}
sql := buildSQL(t, w)
// 期望: THEN UPDATE WHEN MATCHED SET name=:1
for _, want := range []string{
"THEN UPDATE",
"WHEN MATCHED",
"SET name=:1",
} {
if !strings.Contains(sql, want) {
t.Errorf("WhenMatched SQL %q does not contain %q", sql, want)
}
}
}
func TestWhenMatchedBuildMultipleSet(t *testing.T) {
w := WhenMatched{
Set: clause.Set{
{Column: clause.Column{Name: "name"}, Value: "x"},
{Column: clause.Column{Name: "age"}, Value: 1},
},
}
sql := buildSQL(t, w)
if want := "SET name=:1,age=:2"; !strings.Contains(sql, want) {
t.Errorf("WhenMatched SQL %q does not contain %q", sql, want)
}
}
func TestWhenMatchedBuildWithWhereAndDelete(t *testing.T) {
w := WhenMatched{
Set: clause.Set{{Column: clause.Column{Name: "name"}, Value: "x"}},
Where: clause.Where{Exprs: []clause.Expression{
clause.Eq{Column: clause.Column{Name: "id"}, Value: 1},
}},
Delete: clause.Where{Exprs: []clause.Expression{
clause.Eq{Column: clause.Column{Name: "flag"}, Value: 0},
}},
}
sql := buildSQL(t, w)
// 期望: THEN UPDATE WHEN MATCHED SET name=:1WHERE id = :2 DELETE WHERE flag = :3
for _, want := range []string{
"THEN UPDATE",
"WHEN MATCHED",
"SET name=:1",
"WHERE id = :2",
"DELETE WHERE flag = :3",
} {
if !strings.Contains(sql, want) {
t.Errorf("WhenMatched SQL %q does not contain %q", sql, want)
}
}
}
// TestWhenMatchedClauseBuild 验证通过 clause.Clause(gorm 真实构建流程)时的完整输出。
func TestWhenMatchedClauseBuild(t *testing.T) {
w := WhenMatched{
Set: clause.Set{{Column: clause.Column{Name: "name"}, Value: "x"}},
}
sql := buildClauseSQL(t, "WHEN MATCHED", w)
for _, want := range []string{
"WHEN MATCHED",
"THEN UPDATE",
"SET name=:1",
} {
if !strings.Contains(sql, want) {
t.Errorf("WhenMatched SQL %q does not contain %q", sql, want)
}
}
}
func TestWhenMatchedBuildEmptySet(t *testing.T) {
var w WhenMatched
sql := buildSQL(t, w)
if sql != "" {
t.Errorf("WhenMatched with empty Set should generate no SQL, got %q", sql)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func (w WhenNotMatched) Build(builder clause.Builder) {
builder.WriteString(" THEN")
builder.WriteString(" INSERT ")
w.Build(builder)
w.Values.Build(builder)
if len(w.Where.Exprs) > 0 {
builder.WriteString(w.Where.Name())
+93
View File
@@ -0,0 +1,93 @@
package clauses
import (
"strings"
"testing"
"gorm.io/gorm/clause"
)
func TestWhenNotMatchedName(t *testing.T) {
var w WhenNotMatched
if got := w.Name(); got != "WHEN NOT MATCHED" {
t.Errorf("WhenNotMatched.Name() = %q, want %q", got, "WHEN NOT MATCHED")
}
}
func TestWhenNotMatchedBuild(t *testing.T) {
w := WhenNotMatched{
Values: clause.Values{
Columns: []clause.Column{{Name: "name"}, {Name: "age"}},
Values: [][]interface{}{{"x", 1}},
},
}
sql := buildClauseSQL(t, "WHEN NOT MATCHED", w)
// 期望: WHEN NOT MATCHED THEN INSERT (name,age) VALUES (:1,:2)
for _, want := range []string{
"WHEN NOT MATCHED",
"THEN INSERT",
"(name,age)", // 列名
"VALUES (:1,:2)", // VALUES 绑定参数
} {
if !strings.Contains(sql, want) {
t.Errorf("WhenNotMatched SQL %q does not contain %q", sql, want)
}
}
}
func TestWhenNotMatchedBuildWithWhere(t *testing.T) {
w := WhenNotMatched{
Values: clause.Values{
Columns: []clause.Column{{Name: "name"}},
Values: [][]interface{}{{"x"}},
},
Where: clause.Where{Exprs: []clause.Expression{
clause.Eq{Column: clause.Column{Name: "deleted"}, Value: 0},
}},
}
sql := buildSQL(t, w)
for _, want := range []string{
"THEN INSERT",
"(name)",
"VALUES (:1)",
"WHERE deleted = :2",
} {
if !strings.Contains(sql, want) {
t.Errorf("WhenNotMatched SQL %q does not contain %q", sql, want)
}
}
}
func TestWhenNotMatchedBuildEmpty(t *testing.T) {
var w WhenNotMatched
sql := buildSQL(t, w)
if sql != "" {
t.Errorf("WhenNotMatched with empty Columns should generate no SQL, got %q", sql)
}
}
// TestWhenNotMatchedBuildPanicsOnMultipleRows 验证多行插入时按 Oracle 限制 panic。
func TestWhenNotMatchedBuildPanicsOnMultipleRows(t *testing.T) {
w := WhenNotMatched{
Values: clause.Values{
Columns: []clause.Column{{Name: "name"}},
Values: [][]interface{}{{"x"}, {"y"}},
},
}
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic for multiple insert rows")
}
msg, ok := r.(string)
if !ok || !strings.Contains(msg, "cannot insert more than one rows") {
t.Errorf("unexpected panic message: %v", r)
}
}()
buildSQL(t, w)
}