Compare commits

...

5 Commits

Author SHA1 Message Date
Qishuai Liu 99a6d5b37a
Change database method receivers from value to pointer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:25:18 +09:00
Qishuai Liu a201a9b563
Add context-based transaction support with EnsureTx
Add EnsureTx, WithTransaction, and WithoutTransaction for managing
transactions via context. getTxOrDB now checks ctx for transactions,
so WithContext(ctx) automatically uses the transaction if present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:12:58 +09:00
Qishuai Liu 6ea08e79aa
Fix nil pointer dereference in table.GetSQL
Add nil check for scope.Database before accessing dialect,
consistent with the pattern used in field.go and expression.go.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:43:27 +09:00
Qishuai Liu 333f52e702
Make Contains compatible with PostgreSQL and SQLite
Use dialect-specific functions for substring search:
- MySQL: LOCATE(substring, str) > 0
- PostgreSQL: STRPOS(str, substring) > 0
- SQLite: INSTR(str, substring) > 0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:40:32 +09:00
Qishuai Liu 8718eca7b5
Add JSON expression support
Add JsonExpression interface with MySQL JSON functions:
- JsonType, JsonValid, JsonDepth, JsonLength
- JsonExtract, JsonUnquote
- JsonMergePatch, JsonMergePreserve
- JsonContainsPathOne, JsonContainsPathAll

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:40:18 +09:00
7 changed files with 313 additions and 12 deletions

View File

@ -139,7 +139,7 @@ func commaOrderBys(scope scope, orderBys []OrderBy) (string, error) {
return sqlBuilder.String(), nil return sqlBuilder.String(), nil
} }
func getCallerInfo(db database, retry bool) string { func getCallerInfo(db *database, retry bool) string {
if !db.enableCallerInfo { if !db.enableCallerInfo {
return "" return ""
} }

View File

@ -26,6 +26,10 @@ type Database interface {
GetDB() *sql.DB GetDB() *sql.DB
// BeginTx starts a transaction and executes the function f. // BeginTx starts a transaction and executes the function f.
BeginTx(ctx context.Context, opts *sql.TxOptions, f func(tx Transaction) error) error BeginTx(ctx context.Context, opts *sql.TxOptions, f func(tx Transaction) error) error
// EnsureTx ensures the function f runs within a transaction.
// If ctx already contains a transaction started by a previous EnsureTx call, it reuses that transaction.
// Otherwise, it begins a new transaction and stores it in the context.
EnsureTx(ctx context.Context, opts *sql.TxOptions, f func(ctx context.Context) error) error
// Query executes a query and returns the cursor // Query executes a query and returns the cursor
Query(sql string) (Cursor, error) Query(sql string) (Cursor, error)
// QueryContext executes a query with context and returns the cursor // QueryContext executes a query with context and returns the cursor
@ -183,22 +187,27 @@ func Use(driverName string, sqlDB *sql.DB) Database {
} }
} }
func (d database) GetDB() *sql.DB { func (d *database) GetDB() *sql.DB {
return d.db return d.db
} }
func (d database) getTxOrDB() txOrDB { func (d *database) getTxOrDB(ctx context.Context) txOrDB {
if d.tx != nil { if d.tx != nil {
return d.tx return d.tx
} }
if ctx != nil {
if tx, ok := ctx.Value(txContextKey{}).(Transaction); ok {
return tx.GetTx()
}
}
return d.db return d.db
} }
func (d database) Query(sqlString string) (Cursor, error) { func (d *database) Query(sqlString string) (Cursor, error) {
return d.QueryContext(context.Background(), sqlString) return d.QueryContext(context.Background(), sqlString)
} }
func (d database) QueryContext(ctx context.Context, sqlString string) (Cursor, error) { func (d *database) QueryContext(ctx context.Context, sqlString string) (Cursor, error) {
isRetry := false isRetry := false
for { for {
sqlStringWithCallerInfo := getCallerInfo(d, isRetry) + sqlString sqlStringWithCallerInfo := getCallerInfo(d, isRetry) + sqlString
@ -214,7 +223,7 @@ func (d database) QueryContext(ctx context.Context, sqlString string) (Cursor, e
} }
} }
func (d database) queryContextOnce(ctx context.Context, sqlString string, retry bool) (*sql.Rows, error) { func (d *database) queryContextOnce(ctx context.Context, sqlString string, retry bool) (*sql.Rows, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@ -229,7 +238,7 @@ func (d database) queryContextOnce(ctx context.Context, sqlString string, retry
interceptor := d.interceptor interceptor := d.interceptor
var rows *sql.Rows var rows *sql.Rows
invoker := func(ctx context.Context, sql string) (err error) { invoker := func(ctx context.Context, sql string) (err error) {
rows, err = d.getTxOrDB().QueryContext(ctx, sql) rows, err = d.getTxOrDB(ctx).QueryContext(ctx, sql)
return return
} }
@ -246,12 +255,12 @@ func (d database) queryContextOnce(ctx context.Context, sqlString string, retry
return rows, nil return rows, nil
} }
func (d database) Execute(sqlString string) (sql.Result, error) { func (d *database) Execute(sqlString string) (sql.Result, error) {
return d.ExecuteContext(context.Background(), sqlString) return d.ExecuteContext(context.Background(), sqlString)
} }
// ExecuteContext todo Is there need retry? // ExecuteContext todo Is there need retry?
func (d database) ExecuteContext(ctx context.Context, sqlString string) (sql.Result, error) { func (d *database) ExecuteContext(ctx context.Context, sqlString string) (sql.Result, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
@ -266,7 +275,7 @@ func (d database) ExecuteContext(ctx context.Context, sqlString string) (sql.Res
var result sql.Result var result sql.Result
invoker := func(ctx context.Context, sql string) (err error) { invoker := func(ctx context.Context, sql string) (err error) {
result, err = d.getTxOrDB().ExecContext(ctx, sql) result, err = d.getTxOrDB(ctx).ExecContext(ctx, sql)
return return
} }
var err error var err error

View File

@ -81,6 +81,7 @@ type NumberExpression interface {
// StringExpression is the interface of an SQL expression with string value. // StringExpression is the interface of an SQL expression with string value.
type StringExpression interface { type StringExpression interface {
Expression Expression
JsonExpression
Min() UnknownExpression Min() UnknownExpression
Max() UnknownExpression Max() UnknownExpression
Like(other interface{}) BooleanExpression Like(other interface{}) BooleanExpression
@ -134,6 +135,17 @@ type UnknownExpression interface {
Left(count interface{}) StringExpression Left(count interface{}) StringExpression
Right(count interface{}) StringExpression Right(count interface{}) StringExpression
Trim() StringExpression Trim() StringExpression
JsonType() StringExpression
JsonValid() BooleanExpression
JsonDepth() NumberExpression
JsonLength() NumberExpression
JsonExtract(paths ...string) StringExpression
JsonUnquote() UnknownExpression
JsonMergePatch(others ...interface{}) StringExpression
JsonMergePreserve(others ...interface{}) StringExpression
JsonContainsPathOne(paths ...string) BooleanExpression
JsonContainsPathAll(paths ...string) BooleanExpression
} }
type expression struct { type expression struct {
@ -568,7 +580,27 @@ func (e expression) Concat(other interface{}) StringExpression {
} }
func (e expression) Contains(substring string) BooleanExpression { func (e expression) Contains(substring string) BooleanExpression {
return function("LOCATE", substring, e).GreaterThan(0) return expression{builder: func(scope scope) (string, error) {
exprSQL, err := e.GetSQL(scope)
if err != nil {
return "", err
}
substringSQL := quoteString(substring)
dialect := dialectUnknown
if scope.Database != nil {
dialect = scope.Database.dialect
}
switch dialect {
case dialectPostgres:
return "STRPOS(" + exprSQL + ", " + substringSQL + ") > 0", nil
case dialectSqlite3:
return "INSTR(" + exprSQL + ", " + substringSQL + ") > 0", nil
default:
return "LOCATE(" + substringSQL + ", " + exprSQL + ") > 0", nil
}
}, priority: 11}
} }
func (e expression) binaryOperation(operator string, value interface{}, priority priority, isBool bool) expression { func (e expression) binaryOperation(operator string, value interface{}, priority priority, isBool bool) expression {

77
json.go Normal file
View File

@ -0,0 +1,77 @@
package sqlingo
type JsonExpression interface {
JsonType() StringExpression
JsonValid() BooleanExpression
JsonDepth() NumberExpression
JsonLength() NumberExpression
JsonExtract(paths ...string) StringExpression
JsonUnquote() UnknownExpression
JsonMergePatch(others ...interface{}) StringExpression
JsonMergePreserve(others ...interface{}) StringExpression
JsonContainsPathOne(paths ...string) BooleanExpression
JsonContainsPathAll(paths ...string) BooleanExpression
}
func (e expression) JsonType() StringExpression {
return function("JSON_TYPE", e)
}
func (e expression) JsonValid() BooleanExpression {
return function("JSON_VALID", e)
}
func (e expression) JsonDepth() NumberExpression {
return function("JSON_DEPTH", e)
}
func (e expression) JsonLength() NumberExpression {
return function("JSON_LENGTH", e)
}
func (e expression) JsonExtract(paths ...string) StringExpression {
args := make([]interface{}, 0, 1+len(paths))
args = append(args, e)
for _, path := range paths {
args = append(args, path)
}
return function("JSON_EXTRACT", args...)
}
func (e expression) JsonUnquote() UnknownExpression {
return function("JSON_UNQUOTE", e)
}
func (e expression) JsonMergePatch(others ...interface{}) StringExpression {
args := make([]interface{}, 0, 1+len(others))
args = append(args, e)
args = append(args, others...)
return function("JSON_MERGE_PATCH", args...)
}
func (e expression) JsonMergePreserve(others ...interface{}) StringExpression {
args := make([]interface{}, 0, 1+len(others))
args = append(args, e)
args = append(args, others...)
return function("JSON_MERGE_PRESERVE", args...)
}
func (e expression) JsonContainsPathOne(paths ...string) BooleanExpression {
args := make([]interface{}, 0, 2+len(paths))
args = append(args, e)
args = append(args, "one")
for _, path := range paths {
args = append(args, path)
}
return function("JSON_CONTAINS_PATH", args...)
}
func (e expression) JsonContainsPathAll(paths ...string) BooleanExpression {
args := make([]interface{}, 0, 2+len(paths))
args = append(args, e)
args = append(args, "all")
for _, path := range paths {
args = append(args, path)
}
return function("JSON_CONTAINS_PATH", args...)
}

View File

@ -24,7 +24,11 @@ func (t table) GetName() string {
} }
func (t table) GetSQL(scope scope) string { func (t table) GetSQL(scope scope) string {
return t.sqlDialects[scope.Database.dialect] dialect := dialectUnknown
if scope.Database != nil {
dialect = scope.Database.dialect
}
return t.sqlDialects[dialect]
} }
func (t table) getOperatorPriority() int { func (t table) getOperatorPriority() int {

View File

@ -21,6 +21,18 @@ type Transaction interface {
DeleteFrom(table Table) deleteWithTable DeleteFrom(table Table) deleteWithTable
} }
type txContextKey struct{}
// WithTransaction stores the transaction in the context.
func WithTransaction(ctx context.Context, tx Transaction) context.Context {
return context.WithValue(ctx, txContextKey{}, tx)
}
// WithoutTransaction returns a context without the transaction.
func WithoutTransaction(ctx context.Context) context.Context {
return context.WithValue(ctx, txContextKey{}, nil)
}
func (d *database) GetTx() *sql.Tx { func (d *database) GetTx() *sql.Tx {
return d.tx return d.tx
} }
@ -56,3 +68,21 @@ func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions, f func(tx T
isCommitted = true isCommitted = true
return nil return nil
} }
// EnsureTx ensures the function f runs within a transaction.
// If ctx already contains a transaction started by a previous EnsureTx call, it reuses that transaction.
// Otherwise, it begins a new transaction and stores it in the context.
func (d *database) EnsureTx(ctx context.Context, opts *sql.TxOptions, f func(ctx context.Context) error) error {
if ctx == nil {
ctx = context.Background()
}
if _, ok := ctx.Value(txContextKey{}).(Transaction); ok {
return f(ctx)
}
if d.tx != nil {
return f(WithTransaction(ctx, d))
}
return d.BeginTx(ctx, opts, func(tx Transaction) error {
return f(WithTransaction(ctx, tx))
})
}

View File

@ -81,3 +81,152 @@ func TestTransaction(t *testing.T) {
t.Error("should get error here") t.Error("should get error here")
} }
} }
func TestWithTransaction(t *testing.T) {
ctx := context.Background()
// ctx without transaction
if _, ok := ctx.Value(txContextKey{}).(Transaction); ok {
t.Error("should not have transaction")
}
db := newMockDatabase()
err := db.BeginTx(ctx, nil, func(tx Transaction) error {
txCtx := WithTransaction(ctx, tx)
got, ok := txCtx.Value(txContextKey{}).(Transaction)
if !ok {
t.Error("should have transaction")
}
if got.GetTx() != tx.GetTx() {
t.Error("should be the same tx")
}
return nil
})
if err != nil {
t.Error(err)
}
}
func TestWithoutTransaction(t *testing.T) {
db := newMockDatabase()
err := db.BeginTx(context.Background(), nil, func(tx Transaction) error {
txCtx := WithTransaction(context.Background(), tx)
cleanCtx := WithoutTransaction(txCtx)
if _, ok := cleanCtx.Value(txContextKey{}).(Transaction); ok {
t.Error("should not have transaction after WithoutTransaction")
}
return nil
})
if err != nil {
t.Error(err)
}
}
func TestEnsureTx(t *testing.T) {
db := newMockDatabase()
sharedMockConn.mockTx = nil
// EnsureTx should create a new transaction
err := db.EnsureTx(context.Background(), nil, func(ctx context.Context) error {
tx, ok := ctx.Value(txContextKey{}).(Transaction)
if !ok || tx.GetTx() == nil {
t.Error("should have transaction in ctx")
}
return nil
})
if err != nil {
t.Error(err)
}
if !sharedMockConn.mockTx.isCommitted {
t.Error("should be committed")
}
// EnsureTx with nil ctx
err = db.EnsureTx(nil, nil, func(ctx context.Context) error {
return nil
})
if err != nil {
t.Error(err)
}
// EnsureTx should rollback on error
err = db.EnsureTx(context.Background(), nil, func(ctx context.Context) error {
return errors.New("error")
})
if err == nil {
t.Error("should get error here")
}
if !sharedMockConn.mockTx.isRolledBack {
t.Error("should be rolled back")
}
// EnsureTx should reuse transaction from ctx
sharedMockConn.mockTx = nil
err = db.EnsureTx(context.Background(), nil, func(ctx context.Context) error {
outerTx := ctx.Value(txContextKey{}).(Transaction)
// nested EnsureTx should reuse the same tx
return db.EnsureTx(ctx, nil, func(innerCtx context.Context) error {
innerTx := innerCtx.Value(txContextKey{}).(Transaction)
if innerTx.GetTx() != outerTx.GetTx() {
t.Error("nested EnsureTx should reuse the same transaction")
}
return nil
})
})
if err != nil {
t.Error(err)
}
// EnsureTx should reuse transaction from BeginTx
err = db.BeginTx(context.Background(), nil, func(tx Transaction) error {
txDb := tx.(*database)
return txDb.EnsureTx(context.Background(), nil, func(ctx context.Context) error {
ctxTx, ok := ctx.Value(txContextKey{}).(Transaction)
if !ok {
t.Error("should have transaction in ctx")
}
if ctxTx.GetTx() != tx.GetTx() {
t.Error("EnsureTx inside BeginTx should reuse the same transaction")
}
return nil
})
})
if err != nil {
t.Error(err)
}
// EnsureTx should fail if BeginTx fails
sharedMockConn.beginTxError = errors.New("error")
err = db.EnsureTx(context.Background(), nil, func(ctx context.Context) error {
return nil
})
if err == nil {
t.Error("should get error here")
}
sharedMockConn.beginTxError = nil
}
func TestGetTxOrDBWithContext(t *testing.T) {
db := newMockDatabase()
// without tx in ctx, should return db
d := db.(*database)
result := d.getTxOrDB(context.Background())
if result != d.db {
t.Error("should return db when no tx in context")
}
// with tx in ctx, should return tx
err := db.BeginTx(context.Background(), nil, func(tx Transaction) error {
txCtx := WithTransaction(context.Background(), tx)
result := d.getTxOrDB(txCtx)
if result != tx.GetTx() {
t.Error("should return tx from context")
}
return nil
})
if err != nil {
t.Error(err)
}
}