起因
同事使用 xorm 开发修改账户密码接口时,将测试数据库用户表中的密码都修改了。通过排查发现是 xormplus/xorm 旧版本的 bug(新版本已经修复)。
如果使用这个版本,应及时替换为新版本。
github.com/xormplus/xorm v0.0.0-20200312060401-935be4f0f832
原因
xorm 官方对于 update 方法的一个说明:
通过传入map[string]interface{}来进行更新,但这时需要额外指定更新到哪个表,因为通过map是无法自动检测更新哪个表的。
affected, err := engine.Table(new(User)).ID(id).Update(map[string]interface{}{"age":0})
这里的示例 Table(new(User)
是指定一个结构体指针的方法来告诉 xorm 需要更新哪个表。
如果我们使用 Table("account")
时 后面的 ID(1)
就会失效,从而更新了全表。
xormplus/xorm 已经修改了这个 bug。
Fix bug when ID used but no reference table given
bug 重现
sql 语句:
DROP DATABASE IF EXISTS foo;
CREATE DATABASE foo;
use foo;
-- 创建表
CREATE TABLE account (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255),
password VARCHAR(255)
);
-- 插入数据
INSERT INTO account (username, password) VALUES
('user1', 'password1'),
('user2', 'password2'),
('user3', 'password3'),
('user4', 'password4'),
('user5', 'password5'),
('user6', 'password6'),
('user7', 'password7'),
('user8', 'password8'),
('user9', 'password9'),
('user10', 'password10');
go 代码:
package main
import (
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/xormplus/xorm"
)
type Account struct {
Id int64 `xorm:"'id' autoincr pk"`
Username string `xorm:"'username'"`
Password string `xorm:"'password'"`
}
func (a *Account) TableName() string {
return "account"
}
func main() {
var affected int64
var err error
engine, _ := xorm.NewEngine("mysql", "root:root@tcp(127.0.0.1:3306)/foo?charset=utf8mb4")
engine.ShowSQL(true)
affected, err = engine.Table("account").ID(1).Update(map[string]interface{}{"password": "oops"})
fmt.Println(affected)
fmt.Println(err)
affected, err = engine.Table(new(Account)).ID(1).Update(map[string]interface{}{"password": "phew"})
fmt.Println(affected)
fmt.Println(err)
}
结果:
[xorm] [info] 2023/08/05 10:29:38.208707 [SQL][0xc00017ea80] UPDATE `account` SET `password` = ? []interface {}{"oops"}
10
<nil>
[xorm] [info] 2023/08/05 10:29:38.247051 [SQL][0xc0000b6000] UPDATE `account` SET `password` = ? WHERE `id`=? []interface {}{"phew", 1}
1
<nil>
分析
进入engine.Table()
方法中(statement.go 745行)。
全体目光向 *Statement
看齐。
tableNameOrBean
如果是结构体,会赋值 statement.RefTable
。
然后对statement.AltTableName
进行赋值。TableName
方法中会调用
tbNameNoSchema(tableName interface{})
方法(engine_table.go 56行)获取 tbName
。
大概逻辑为如果传递的是结构体,那么就会调用实现的TableName返回表名,如果是字符串就将字符串设置为表名,以及默认值等等,这里就不详细说了。
// Table tempororily set table name, the parameter could be a string or a pointer of struct
func (statement *Statement) Table(tableNameOrBean interface{}) *Statement {
v := rValue(tableNameOrBean)
t := v.Type()
if t.Kind() == reflect.Struct {
var err error
statement.RefTable, err = statement.Engine.autoMapType(v)
if err != nil {
statement.Engine.logger.Error(err)
return statement
}
}
statement.AltTableName = statement.Engine.TableName(tableNameOrBean, true)
return statement
}
然后 debug 到 update 方法(session_update.go 146行)。通过反射对 bean 进行类型判断,我们传递的 bean 是一个 map,如果会走 isMap
的逻辑。
然后 table := session.statement.RefTable
,因为我们传递的是表名,所以table 为nil 。
debug 到 253行 if err = session.statement.processIDParam(); err != nil
,然后进入。如果 statement.RefTable
为 nil, 返回的 error
也是 nil。
if statement.idParam == nil || statement.RefTable == nil {
return nil
}
如果 statement.RefTable
不为nil,也就是使用 Table(new(Account))
。 那么走如下代码,遍历 PKColumns
,然后添加到 statement.cond
。
for i, col := range statement.RefTable.PKColumns() {
var colName = statement.colName(col, statement.TableName())
statement.cond = statement.cond.And(builder.Eq{colName: (*(statement.idParam))[i]})
}
下图是使用 Table(new(Account))
来指定 ID 进行更新操作。其中 statement.cond
是保存 where 条件相关信息。
然后回到
Update
方法,执行下述代码后,condSQL
的值为 WHERE id = ?
,最后sqlStr = UPDATE account SET password = ? WHERE id = ?
。
condSQL, condArgs, err = builder.ToSQL(cond)
if err != nil {
return 0, err
}
if len(condSQL) > 0 {
condSQL = "WHERE " + condSQL
}
// ......
sqlStr = fmt.Sprintf("UPDATE %v%v SET %v %v%v",
top,
tableAlias,
strings.Join(colNames, ", "),
fromSQL,
condSQL)
所以当我们是通过字符串指定的表名,session.statement.RefTable
为 nil,导致statement.RefTable.PKColumns()
为空,最后保存 where 条件的 statement.cond
为空,最后生成的语句就是更新全表了,
sqlStr = UPDATE account SET password = ?
fix
修改这个 bug 也很简单,当 statement.RefTable
为 nil 时,不应该返回 nil,应该返回一个 sentinel error。