PHP——了解 Laravel 预处理的数据绑定

通过这次联合(union)查询了解 Laravel 预处理的数据绑定

通过这次联合(union)查询了解 Laravel 预处理的数据绑定

最近做一个需求,业务要求把两种不同的记录放在同一个列表中进去筛选查询,需要查询两种记录关联的客户信息。虽然两种记录字段差距很大,但是好在我们需要的字段不多,用联合(union)查询也能做。

开始实验

这里先描述本次实验的大致情况

  • version: Laravel 5.5
  • Model:
    • customers -> trial_records 一对多
    • customers -> onetoone_records 一对多
  • 需求:
    • trial_recordsonetoone_records 联合起来并关联 customers,通过记录的状态,记录的开始时间和客户的创建时间来筛选查询结果

第一次尝试

为了方便对比,这次的查询条件都是写死了

联合查询

查询上课时间在 4 月份的,记录状态为成功(2)的

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
$classStartAt = Carbon::parse('2019-04-01 10:07:57')->startOfMonth();
$classEndAt = Carbon::parse('2019-04-01 10:07:57')->endOfMonth();

$queryOne = DB::table('trial_records')->select([
'id',
'customer_id',
'status',
DB::raw('@type := 2 as type'),
'start_at'
]);

$queryTwo = DB::table('onetoone_records')->select([
'id',
'customer_id',
'status',
DB::raw('@type := 1 as type'),
'start_time as start_at'
]);

//是的,两个表用于表示开始时间的字段名不同
$queryOne->whereBetween('start_at', [$classStartAt, $classEndAt]);
$queryTwo->whereBetween('start_time', [$classStartAt, $classEndAt]);

$queryOne->where('status', 2);
$queryTwo->where('status', 2);

$queryUnion = $queryOne->union($queryTwo);

关联客户表

将联合查询结果起个别名,通过 customer_id 关联

1
2
3
4
5
$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->join('customers', function ($q) {
/** @var JoinClause $q */
$q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});

最后的条件

对客户的创建时间筛选

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$startAt = Carbon::parse('2019-03-01 10:07:57')->startOfMonth();
$endAt = Carbon::parse('2019-03-01 10:07:57')->endOfMonth();

$query->whereBetween('customers.created_at', [$startAt, $endAt]);

$res = $query->select([
'customers.name',
'customers.id as customer_id',
'customers.created_at',
'data.status',
'data.start_at',
'data.type',
'data.id',
])->get();

return response()->json($res)->setStatusCode(200)

执行程序

Postman 测试

执行结果

1
2
3
4
5
6
{
"msg": "SQLSTATE[HY093]: Invalid parameter number (SQL: select `customers`.`name`, `customers`.`id` as `customer_id`, `customers`.`created_at`, `data`.`status`, `data`.`start_at`, `data`.`type`, `data`.`id` from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at` from `trial_records` where `start_at` between 2019-03-01 00:00:00 and 2019-03-31 23:59:59 and `status` = ?) union (select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at` from `onetoone_records` where `start_time` between ? and ? and `status` = ?)) as data inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null where `customers`.`created_at` between ? and ?)",
"code": 400,
"status": "FAILED",
"data": []
}

SQLSTATE[HY093]: Invalid parameter number (SQL: select customers.name, customers.id as customer_id, customers.created_at, data.status, data.start_at, data.type, data.id from ((select id, customer_id, status, @type := 2 as type, start_at from trial_records where start_at between 2019-03-01 00:00:00 and 2019-03-31 23:59:59 and status = ?) union (select id, customer_id, status, @type := 1 as type, start_time as start_at from onetoone_records where start_time between ? and ? and status = ?)) as data inner join customers on customers.id = data.customer_id and customers.deleted_at is null where customers.created_at between ? and ?)

报错了,看到爆出错误的 sql 中有很多问号,除了第一个start_at 有查询时间,剩下的都是问号。

分析

为什么呢?我们都知道问号是预处理的占位符,这显然是数据没有绑定上。

回看代码,关联客户表的时候,我们将 $queryUnion 的 sql 拼接了,但是原属于 $queryUnion 的数据绑定并没有绑定到 $query 上。

OK,找到了原因,我们继续。

第二次尝试

修改关联客户表这一段,继续查询

修改代码

mergeBindings

1
2
3
4
5
6
$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->mergeBindings($queryUnion)
->join('customers', function ($q) {
/** @var JoinClause $q */
$q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});

执行代码

1
2
3
4
5
6
{
"msg": "操作成功",
"code": 200,
"data": [],
"status": "OK"
}

这次没报错,但是空数据了。

查看日志

日志中的 sql 长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
select `customers`.`name`,
`customers`.`id` as `customer_id`,
`customers`.`created_at`,
`data`.`status`,
`data`.`start_at`,
`data`.`type`,
`data`.`id`
from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at`
from `trial_records`
where `start_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
and `status` = '2')
union
(select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at`
from `onetoone_records`
where `start_time` between '2019-03-01 00:00:00' and '2019-03-31 23:59:59'
and `status` = '2019-04-01 00:00:00')) as data
inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null
where `customers`.`created_at` between '2019-04-30 23:59:59' and '2'

这样的 sql 能查询出来数据才怪了,我们可以看到数据的绑定一团乱麻了已经。
为什么会这样呢?

源码分析

我们查看 Illuminate\Database\Query\Builder

get()

runSelect 就是查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function get($columns = ['*'])
{
$original = $this->columns;

if (is_null($original)) {
$this->columns = $columns;
}

$results = $this->processor->processSelect($this, $this->runSelect());

$this->columns = $original;

return collect($results);
}

runSelect()

看到了 getBindings()

1
2
3
4
5
6
protected function runSelect()
{
return $this->connection->select(
$this->toSql(), $this->getBindings(), ! $this->useWritePdo
);
}

getBindings()

属性 bindings 被转成一维数组返回

1
2
3
4
public function getBindings()
{
return Arr::flatten($this->bindings);
}

Arr::flatten() 是一个多维数组转一位数组的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Illuminate\Support\Arr
public static function flatten($array, $depth = INF)
{
$result = [];

foreach ($array as $item) {
$item = $item instanceof Collection ? $item->all() : $item;

if (! is_array($item)) {
$result[] = $item;
} elseif ($depth === 1) {
$result = array_merge($result, array_values($item));
} else {
$result = array_merge($result, static::flatten($item, $depth - 1));
}
}

return $result;
}

看来问题的关键是 bindings
我们继续分析这个属性

bindings

bindings 是一个二维数组,注意是公开属性

1
2
3
4
5
6
7
8
public $bindings = [
'select' => [],
'join' => [],
'where' => [],
'having' => [],
'order' => [],
'union' => [],
];

看到这里我已经大概明白为什么
bindings 这么设计并不难理解, laravel 会将所有绑定的数据分好类,这样即使你的query 没有按照 select > join > where > having > order > union 这样的顺序来写,查询的时候也一样能够对应准确的占位符号。

在我们这个例子中为什么就发生了偏差呢?
mergeBindings 有什么问题吗?

mergeBindings()

这就是递归合并了两个不同 querybingdings,注意这是一个公开的方法

1
2
3
4
5
6
public function mergeBindings(self $query)
{
$this->bindings = array_merge_recursive($this->bindings, $query->bindings);

return $this;
}

打印 bindings

修改代码打印 bindings

1
2
3
4
5
6
7
8
\Log::info($queryUnion->bindings);
$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->mergeBindings($queryUnion)
->join('customers', function ($q) {
/** @var JoinClause $q */
$q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});
\Log::info($query->bindings);

查看结果

$queryUnion->bindings

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
{
"select": [],
"join": [],
"where": [
{
"date": "2019-04-01 00:00:00.000000",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
{
"date": "2019-04-30 23:59:59.999999",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
2
],
"having": [],
"order": [],
"union": [
{
"date": "2019-04-01 00:00:00.000000",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
{
"date": "2019-04-30 23:59:59.999999",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
2
]
}

$query->bindings

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
{
"select": [],
"join": [],
"where": [
{
"date": "2019-04-01 00:00:00.000000",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
{
"date": "2019-04-30 23:59:59.999999",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
2
],
"having": [],
"order": [],
"union": [
{
"date": "2019-04-01 00:00:00.000000",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
{
"date": "2019-04-30 23:59:59.999999",
"timezone_type": 3,
"timezone": "Asia/Shanghai"
},
2
]
}

看到这里,其实已经很清楚了。mergeBindings() 只是对两个 querybindings 的简单递归合并。

在我这里的情况并不适用,因为我这里是把 $queryUnion 的结果集作为一个表进行了内链接查询,在 $queryUnion 中的 bindings 应该全部放到 $query->bindings['join'] 中才对。

明白了原因就能找到应对的方案了。

第三次尝试

修改关联查询代码

$queryUnion->bindings 转成一维数组,放进 $queryUnion->bindings['join'],然后在合并

1
2
3
4
5
6
7
8
$queryUnion->bindings['join'] = Arr::flatten($queryUnion->bindings);

$query = DB::table(DB::raw('(' . $queryUnion->toSql() . ') as data'))
->mergeBindings($queryUnion)
->join('customers', function ($q) {
/** @var JoinClause $q */
$q->on('customers.id', '=', 'data.customer_id')->whereNull('customers.deleted_at');
});

执行

很好,是我想要的结果

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
{
"msg": "操作成功",
"code": 200,
"data": [
{
"name": "test",
"customer_id": 93902,
"created_at": "2019-03-14 14:10:45",
"status": 2,
"start_at": "2019-04-11 18:34:03",
"type": 2,
"id": 1
},
{
"name": "test",
"customer_id": 93905,
"created_at": "2019-03-14 15:10:45",
"status": 2,
"start_at": "2019-04-12 20:34:03",
"type": 1,
"id": 2
}
],
"status": "OK"
}

相应的 sql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
select `customers`.`name`,
`customers`.`id` as `customer_id`,
`customers`.`created_at`,
`data`.`status`,
`data`.`start_at`,
`data`.`type`,
`data`.`id`
from ((select `id`, `customer_id`, `status`, @type := 2 as type, `start_at`
from `trial_records`
where `start_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
and `status` = '2')
union
(select `id`, `customer_id`, `status`, @type := 1 as type, `start_time` as `start_at`
from `onetoone_records`
where `start_time` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'
and `status` = '2')) as data
inner join `customers` on `customers`.`id` = `data`.`customer_id` and `customers`.`deleted_at` is null
where `customers`.`created_at` between '2019-04-01 00:00:00' and '2019-04-30 23:59:59'

总结

这个问题的解决办法没有什么太好的能够统一解决的,只能说我们需要了解有这个问题,那么编写代码的时候应当注意,注意避坑。

在我这样的业务中,我选择了将第一个 query 中的 bingdings 全部放到 join 段,因为我的业务中,这些数据确实应该存在于第二个 queryjoin 段。

所以遇到这样的问题,大家还是应该灵活去应对。

当然,如果你有其他的想法,欢迎表达!

文章不错,你都不请我喝杯茶,就是说你呀!
0%
upyun