xormplus/xorm 旧版本bug,update时ID方法失效,从而更新全表

158 阅读3分钟

起因

同事使用 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 条件相关信息。 image.png 然后回到 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。

image.png

参考资料