feat: 新增 BooleanFunctionLookup 支持函数式操作符

- sqlgen: 新增 BooleanFunctionLookup,生成 FUNC(LHS, RHS) 格式,优先级高于 BooleanOperatorLookup
- dialect/duckdb: 使用 regexp_matches 替代 ~ 实现部分匹配,等价于 MySQL REGEXP
- sqlgen/expression_sql_generator_test: 补充 BooleanFunctionLookup 测试用例,修正 encode_error 前缀
- sqlgen/sql_dialect_options_test: 验证 BooleanFunctionLookup 默认为 nil
- dialect/duckdb_test: 新增 regexp_matches SQL 生成测试
This commit is contained in:
2026-05-21 13:34:04 +08:00
parent 1288c4919c
commit 37581f3a65
6 changed files with 74 additions and 3 deletions
+7
View File
@@ -4,6 +4,7 @@ import (
_ "github.com/marcboeker/go-duckdb" _ "github.com/marcboeker/go-duckdb"
"git.fsdpf.net/go/db" "git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/exp"
) )
func DialectOptions() *db.SQLDialectOptions { func DialectOptions() *db.SQLDialectOptions {
@@ -30,6 +31,12 @@ func DialectOptions() *db.SQLDialectOptions {
// Use double quotes for identifiers (PostgreSQL-style) // Use double quotes for identifiers (PostgreSQL-style)
do.QuoteRune = '"' do.QuoteRune = '"'
// regexp_matches(col, pattern) 做部分匹配,等价于 MySQL REGEXP
do.BooleanFunctionLookup = map[exp.BooleanOperation][]byte{
exp.RegexpLikeOp: []byte("regexp_matches"),
exp.RegexpILikeOp: []byte("regexp_matches"),
}
return do return do
} }
+26
View File
@@ -107,6 +107,32 @@ func TestJoinSQL(t *testing.T) {
assert.Contains(t, sql, `ON ("users"."id" = "orders"."user_id")`) assert.Contains(t, sql, `ON ("users"."id" = "orders"."user_id")`)
} }
func TestRegexpSQL(t *testing.T) {
db := dbv2.Dialect("duckdb")
// RegexpLike → regexp_matches(col, pattern)
ds := db.From("docs").Where(dbv2.C("content").RegexpLike("简介"))
sql, _, err := ds.ToSQL()
assert.NoError(t, err)
assert.Equal(t, `SELECT * FROM "docs" WHERE regexp_matches("content", '简介')`, sql)
// RegexpILike → 同样生成 regexp_matches
ds = db.From("docs").Where(dbv2.C("content").RegexpILike("简介"))
sql, _, err = ds.ToSQL()
assert.NoError(t, err)
assert.Equal(t, `SELECT * FROM "docs" WHERE regexp_matches("content", '简介')`, sql)
}
func TestRegexpPreparedSQL(t *testing.T) {
db := dbv2.Dialect("duckdb")
ds := db.From("docs").Prepared(true).Where(dbv2.C("content").RegexpLike("简介"))
sql, args, err := ds.ToSQL()
assert.NoError(t, err)
assert.Equal(t, `SELECT * FROM "docs" WHERE regexp_matches("content", $1)`, sql)
assert.Equal(t, []interface{}{"简介"}, args)
}
func TestWindowFunction(t *testing.T) { func TestWindowFunction(t *testing.T) {
db := dbv2.Dialect("duckdb") db := dbv2.Dialect("duckdb")
+13 -1
View File
@@ -386,10 +386,22 @@ func (esg *expressionSQLGenerator) aliasedExpressionSQL(b sb.SQLBuilder, aliased
// Generates SQL for a BooleanExpresion (e.g. I("a").Eq(2) -> "a" = 2) // Generates SQL for a BooleanExpresion (e.g. I("a").Eq(2) -> "a" = 2)
func (esg *expressionSQLGenerator) booleanExpressionSQL(b sb.SQLBuilder, operator exp.BooleanExpression) { func (esg *expressionSQLGenerator) booleanExpressionSQL(b sb.SQLBuilder, operator exp.BooleanExpression) {
operatorOp := operator.Op()
// 函数式操作符:生成 FUNC(LHS, RHS)
if fn, ok := esg.dialectOptions.BooleanFunctionLookup[operatorOp]; ok {
b.Write(fn)
b.WriteRunes(esg.dialectOptions.LeftParenRune)
esg.Generate(b, operator.LHS())
b.WriteRunes(esg.dialectOptions.CommaRune, esg.dialectOptions.SpaceRune)
esg.Generate(b, operator.RHS())
b.WriteRunes(esg.dialectOptions.RightParenRune)
return
}
b.WriteRunes(esg.dialectOptions.LeftParenRune) b.WriteRunes(esg.dialectOptions.LeftParenRune)
esg.Generate(b, operator.LHS()) esg.Generate(b, operator.LHS())
b.WriteRunes(esg.dialectOptions.SpaceRune) b.WriteRunes(esg.dialectOptions.SpaceRune)
operatorOp := operator.Op()
if val, ok := esg.dialectOptions.BooleanOperatorLookup[operatorOp]; ok { if val, ok := esg.dialectOptions.BooleanOperatorLookup[operatorOp]; ok {
b.Write(val) b.Write(val)
} else { } else {
+20 -2
View File
@@ -127,8 +127,8 @@ func (esgs *expressionSQLGeneratorSuite) TestGenerate_UnsupportedType() {
type strct struct{} type strct struct{}
esgs.assertCases( esgs.assertCases(
sqlgen.NewExpressionSQLGenerator("test", sqlgen.DefaultDialectOptions()), sqlgen.NewExpressionSQLGenerator("test", sqlgen.DefaultDialectOptions()),
expressionTestCase{val: strct{}, err: "dbv2_encode_error: Unable to encode value {}"}, expressionTestCase{val: strct{}, err: "db_encode_error: Unable to encode value {}"},
expressionTestCase{val: strct{}, err: "dbv2_encode_error: Unable to encode value {}", isPrepared: true}, expressionTestCase{val: strct{}, err: "db_encode_error: Unable to encode value {}", isPrepared: true},
) )
} }
@@ -624,6 +624,24 @@ func (esgs *expressionSQLGeneratorSuite) TestGenerate_BooleanExpression() {
expressionTestCase{val: ident.NotILike("a%"), err: "db: boolean operator 'notilike' not supported"}, expressionTestCase{val: ident.NotILike("a%"), err: "db: boolean operator 'notilike' not supported"},
expressionTestCase{val: ident.NotILike(re), err: "db: boolean operator 'regexpnotilike' not supported"}, expressionTestCase{val: ident.NotILike(re), err: "db: boolean operator 'regexpnotilike' not supported"},
) )
// BooleanFunctionLookup 生成 FUNC(LHS, RHS) 格式,优先级高于 BooleanOperatorLookup
opts2 := sqlgen.DefaultDialectOptions()
opts2.BooleanFunctionLookup = map[exp.BooleanOperation][]byte{
exp.RegexpLikeOp: []byte("regexp_matches"),
exp.RegexpILikeOp: []byte("regexp_matches"),
}
esgs.assertCases(
sqlgen.NewExpressionSQLGenerator("test", opts2),
// 函数调用格式
expressionTestCase{val: ident.Like(re), sql: `regexp_matches("a", '[ab]')`},
expressionTestCase{val: ident.Like(re), sql: `regexp_matches("a", ?)`, isPrepared: true, args: []interface{}{"[ab]"}},
expressionTestCase{val: ident.ILike(re), sql: `regexp_matches("a", '[ab]')`},
expressionTestCase{val: ident.ILike(re), sql: `regexp_matches("a", ?)`, isPrepared: true, args: []interface{}{"[ab]"}},
// 未在 BooleanFunctionLookup 中的操作符仍走中缀格式
expressionTestCase{val: ident.Eq(1), sql: `("a" = 1)`},
expressionTestCase{val: ident.NotLike(re), sql: `("a" !~ '[ab]')`},
)
} }
func (esgs *expressionSQLGeneratorSuite) TestGenerate_BitwiseExpression() { func (esgs *expressionSQLGeneratorSuite) TestGenerate_BitwiseExpression() {
+2
View File
@@ -219,6 +219,8 @@ type (
// exp.RegexpNotILikeOp: []byte("!~*"), // exp.RegexpNotILikeOp: []byte("!~*"),
// }) // })
BooleanOperatorLookup map[exp.BooleanOperation][]byte BooleanOperatorLookup map[exp.BooleanOperation][]byte
// 函数式布尔操作符,生成 FUNC(LHS, RHS) 格式,优先级高于 BooleanOperatorLookup
BooleanFunctionLookup map[exp.BooleanOperation][]byte
// A map used to look up BitwiseOperations and their SQL equivalents // A map used to look up BitwiseOperations and their SQL equivalents
// (Default=map[exp.BitwiseOperation][]byte{ // (Default=map[exp.BitwiseOperation][]byte{
// exp.BitwiseInversionOp: []byte("~"), // exp.BitwiseInversionOp: []byte("~"),
+6
View File
@@ -44,6 +44,12 @@ func (sfts *sqlFragmentTypeSuite) TestOptions_SQLFragmentType() {
} }
} }
func (sfts *sqlFragmentTypeSuite) TestDefaultDialectOptions_BooleanFunctionLookup() {
opts := sqlgen.DefaultDialectOptions()
// 默认不设置函数式操作符,各方言按需覆盖
sfts.Nil(opts.BooleanFunctionLookup)
}
func TestSQLFragmentType(t *testing.T) { func TestSQLFragmentType(t *testing.T) {
suite.Run(t, new(sqlFragmentTypeSuite)) suite.Run(t, new(sqlFragmentTypeSuite))
} }