使用MySQL数据库,有些字段设置为NOT NULL,默认值非0或非空字符串,如果用Phalcon的Model连续进行两次save()操作(第二次操作需要用到第一次生成的id,所以不能合并成一次,本文中是pid字段是根据id字段生成的,确保二者的一一对应关系),并且没有指定这些字段的值,最终的结果是这些字段的值变成了0或空字符串,而不是数据库里设置的默认值。
问题复现
数据表定义:1
2
3
4
5
6
7
8
9
10
11
12
13CREATE TABLE `product` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`pid` bigint(20) DEFAULT NULL COMMENT '产品id',
`type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '类型id',
`status` tinyint(4) DEFAULT '1' COMMENT '状态',
`provider` varchar(32) NOT NULL DEFAULT 'zlc' COMMENT '供应商',
`name` varchar(64) DEFAULT NULL COMMENT '名称',
`price` decimal(7,2) DEFAULT NULL COMMENT '价格',
`quantity` int(11) DEFAULT NULL COMMENT '数量',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='产品'
代码层面,在Product.php里预先定义好了所有的列,并且去掉Model对于not null的验证:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Product extends \Phalcon\Mvc\Model
{
public $id;
public $pid;
public $type;
public $status;
public $provider;
public $name;
public $price;
public $quantity;
public $create_time;
public $update_time;
/**
* 在模型初始化的时候设置模型不用进行not null验证,当模型比较多时可以在BaseModel中进行设置
*/
public function initialize() {
//去掉model对于not null的验证
Phalcon\Mvc\Model::setup(['notNullValidations' => false]);
}
/**
* 获取模型字符串错误信息
*/
public function getErrorAsString() {
$error_str = join(';', array_map(function ($v) {
return $v->getMessage();
}, $this->getMessages()));
return $error_str;
}
}
简单的逻辑代码在ProductController.php中给出:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class ProductController extends \Phalcon\Mvc\Controller
{
/**
* 创建一个product
*/
public function createAction() {
if (!$this->request->isPost()) {
$response = [
'code' => 1,
'msg' => 'Post method is needed',
];
return $this->response->setJsonContent($response);
}
$product = new Product();
$product->name = $this->request->getPost('name');
$product->price = $this->request->getPost('price');
$product->quantity = $this->request->getPost('quantity', 'int', 0);
$succ = $product->save();
if (!$succ) {
error_log(__METHOD__ . ' something went wrong:' . $product->getErrorAsString());
throw new \Exception('创建product失败');
}
// 为了不对外暴露产品id,生成一个与id唯一对应的pid暴露给外界,这里只是简单的拼接时间戳
$product->pid = $product->id . time();
$succ = $product->save();
if ($succ) {
$code = 0;
$msg = 'Create the product successfully!';
} else {
error_log(__METHOD__ . ' something went wrong:' . $product->getErrorAsString());
$code = 1;
$msg = 'Fail to create the product';
}
$response = [
'code' => $code,
'msg' => $msg,
];
return $this->response->setJsonContent($response);
}
}
通过curl请求http://phalcon.zuolicong.com/product/create 接口(我在本地把该项目的hosts设置成了phalcon.zuolicong.com),命令如下:1
curl -i -d "name=computer&price=6000.00&quantity=2017" http://phalcon.zuolicong.com/product/create
请求成功,查看数据库结果如下:
没有传type、status、provider三个字段,但是数据库里保存的却不是这三个字段的默认值,这是怎么回事?
使用psysh进行逐步操作以分析问题
使用psysh引入该项目的入口文件,直接通过Product Model进行逐步操作复现问题。先实例化一个Product,并对type、status、provider三个字段进行赋值,然后进行第一次save()操作,操作后发现type、status、provider三个字段的值都是null:
此时数据库里的结果如下:
可以看出第一次save()之后type、status、provider三个字段保存的都是默认值,那么问题应该就是出在第二次save()之后了。此时对该实例的pid字段进行赋值之后再进行第二次save()操作,果然这三个字段的值分别变成了0、NULL和空字符串。此时问题就明朗了,第一次save()之后实例未被赋值的字段默认为null,如果再进行一次save()操作(相当于update操作),由于type和provider设置了NOT NULL,更新为null值时MySQL会自动进行转换(此时MySQL的sql_mode不能设为STRICT_TRANS_TABLES,否则将不会进行转换,而是直接中止操作,参考http://keithlan.github.io/2015/07/14/mysql_error_1048/ 和 http://xstarcd.github.io/wiki/MySQL/MySQL-sql-mode.html ),整型会转成0,字符串类型则转成空字符串(和null是有区别的),实际上timestamp类型也会转成操作的当前时间,这点从create_time字段上就可以看出来,此外,如果将NULL插入具有AUTO_INCREMENT属性的整数列,将插入序列中的下一个编号。 而status没有设定NOT NULL,直接更新为null。
解决方案
知道了原因出在实例第一次save()之后没有被赋值的字段默认为null,想到了两个解决方案如下:
首先想到的方案就是在第二次进行save()更新操作之前把未赋值的且数据库设置了非0或非空默认值的字段手动赋为默认值,这种方案虽然可行,但是比较麻烦,并且在代码层面赋了默认值,数据库设置的默认值就没有意义了;
第二个方案是利用save()方法的第二个参数$whiteList,我们来看一下save()方法的参数:
1
2
3
4
5
6/**
* @param array $data
* @param array $whiteList
* @return boolean
*/
public function save($data = null, $whiteList = null) {}
当设置了save()方法的$whiteList参数时,将只对$whiteList里面的字段进行操作,其它字段会忽略,即第二次save()操作改为如下形式:1
$succ = $product->save(['pid' => $product->id . time()], ['pid']);
这种方案理论上是可行的,然而实际测试结果是并没有什么卵用,不知道是Phalcon的bug还是别的什么原因,有待进一步考证。
更新之关于解决方案的讨论
今天特地研究了一下Phalcon的源码,发现上述解决方案2中对save($data = null, $whiteList = null)函数的理解有误,该函数的运行原理是,如果$data为非空数组,则调用assign($data, $dataColumnMap = null, $whiteList = null)函数对模型的各个属性进行赋值,如果$whiteList参数存在,则只会对$whiteList中给出的属性进行赋值,然后进行插入(调用_doLowInsert()函数)或者更新(调用_doLowUpdate()函数)操作,对于不在$whiteList参数里的属性,虽然在assign()函数里没有赋值,最终也会进行操作,并不是像之前理解的只会insert或者update参数$whiteList里的字段。通过对源码的研究(相关源码注释参考下一节),我找到了两个比较合理且行之有效的方案:
方案一. 使用dynamic update
在模型里设置dynamic update,并在第二次save(实际上执行的是update操作)前设置快照数据。
Product.php里的initialize()函数改动如下:1
2
3
4
5
6
7
8
9
10class Product extends \Phalcon\Mvc\Model
{
public function initialize() {
// 去掉model对于not null的验证
Phalcon\Mvc\Model::setup(['notNullValidations' => false]);
// 使用dynamic update
$this->useDynamicUpdate(true);
}
}
ProductController.php里createAction()的核心代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class ProductController extends \Phalcon\Mvc\Controller
{
/**
* 创建一个product
*/
public function createAction() {
$product = new Product();
$product->name = $this->request->getPost('name');
$product->price = $this->request->getPost('price');
$product->quantity = $this->request->getPost('quantity', 'int', 0);
$succ = $product->save();
// 设置快照数据,后面修改pid字段后和快照数据对比,就只有pid字段发生了改变,由于使用了dynamic update,只会更新pid字段。需要注意的是,启用dynamic update,只有查询数据库之后才会自动设置快照数据,我用的老版本创建和更新操作并不会自动设置快照数据,因此这里需要手动设置,未赋值的为null的字段也包含在快照数组中。新版本如果使用了dynamic update,创建和更新的时候也会自动设置快照数组,和这里手动设置的效果一样。
$product->setSnapshotData($product->toArray());
// 为了不对外暴露产品id,生成一个与id唯一对应的pid暴露给外界,这里只是简单的拼接时间戳
$product->pid = $product->id . time();
$succ = $product->save();
}
}
方案二. 使用Phalcon\Db\Adapter\Pdo\Mysql类的updateAsDict()函数
updateAsDict()函数可以更新指定的字段,忽略其它字段。对于第二次使用save()函数进行更新操作,实际上源码最终是使用Phalcon\Db\Adapter\Pdo\Mysql类的update()函数实现更新的,和updateAsDict()函数类似。ProductController.php里createAction()的核心代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class ProductController extends \Phalcon\Mvc\Controller
{
/**
* 创建一个product
*/
public function createAction() {
$product = new Product();
/**
* 这里不用担心sql注入,Values assigned directly or via the array of attributes are escaped/sanitized according to the related attribute data type. So you can pass an insecure array without worrying about possible SQL injections.见https://docs.phalconphp.com/zh/latest/reference/models.html
*/
$succ = $product->save($this->request->getPost(), ['name', 'price', 'quantity']);
// 获取一个Phalcon\Db\Adapter\Pdo\Mysql实例
$connection = $product->getWriteConnection();
// 要更新的字段,为了不对外暴露产品id,生成一个与id唯一对应的pid暴露给外界,这里只是简单的拼接时间戳
$updateData = ['pid' => $product->id . time()];
// 执行更新操作
$succ = $connection->updateAsDict('product', $updateData, 'id=' . $product->id);
}
}
cphalcon相关源码注释
cphalcon相关源码注释见https://github.com/zuolicong/cphalcon/blob/master/phalcon/mvc/model.zep (中文是本人加的注释),主要涉及到save(),assign(),_doLowInsert()和_doLowInsert()几个函数,如果文件不方便看也可结合commit内容看https://github.com/zuolicong/cphalcon/commit/d1787d406aa0a1600c78f04a1207f520071169db。