ThinkPHP Pgsql 驱动聚合查询引用陷阱

在 ThinkPHP 项目里用 PostgreSQL 做统计查询时踩到一个驱动层缺陷:带聚合函数或别名的查询,生成的 SQL 引用不正确。这篇文章完整记录复现过程、根因和规避方案。

问题现象

一个看起来很常规的计数查询:

1
2
3
$count = Db::name('user')
->where('status', 1)
->count();

预期生成:

ThinkPHP Pgsql Builder的 parseKey 引用问题:期望 SQL 与实际 SQL 对照

1
SELECT COUNT(*) AS tp_count FROM "tb_user" WHERE "status" = 1 LIMIT 1

实际生成的 SQL 里,聚合表达式被当成了普通字段名处理,tp_count 别名没有被正确剥离,PostgreSQL 直接报语法错误。同类问题在 field('COUNT(id) as total')、group() 之后的聚合列上都会出现。

根因定位

问题出在 Pgsql 驱动的 parseKey 方法。ThinkPHP 的查询构造器会把每个字段表达式过一遍 parseKey,由各驱动决定如何加引号。Pg 驱动的实现大致是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function parseKey($key, $options = [])
{
$key = trim($key);
if (strpos($key, '.') !== false) {
// 处理 表.字段 形式
...
}
if (strpos($key, '->') !== false) {
// 处理 JSON 操作符
...
}
// 普通字段:加双引号
return '"' . $key . '"';
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

关键在于:**`parseKey` 无法区分"这是一个裸字段名"和"这是一个函数表达式"**。MySQL 驱动对这类表达式有额外的正则过滤,Pgsql 驱动的过滤规则更激进也更脆弱——遇到 `COUNT(*)`、`SUM(x) + 1`、`DISTINCT id` 这类输入时,会把整串当作字段名加引号,生成 `"COUNT(*)"` 这种非法 SQL。

`count()` 方法内部走的是 `field('COUNT(' . $field . ') AS ' . $alias)` 的链路,同样踩中。

## 复现脚本

```php
// 在控制器或 tinker 里直接跑
try {
$sql = Db::name('user')
->where('status', 1)
->fetchSql()
->count();
dump($sql); // 观察生成的 SQL
} catch (\Exception $e) {
dump($e->getMessage());
}

用 fetchSql() 打印而不是真执行,能直接看到被破坏的 SQL。

规避方案

在驱动修复之前,业务代码绕开这三类写法:

1. 聚合统计改用 query() 原生查询

1
2
3
4
$total = (int) Db::query(
'SELECT COUNT(*) AS total FROM "tb_user" WHERE "status" = ?',
[1]
)[0]['total'];

直写 SQL 最稳,参数化绑定防注入,统计类查询本来也不复杂。

2. 需要构造器链式调用时,用 fieldRaw() 传表达式

1
2
3
4
5
$list = Db::name('order')
->fieldRaw('user_id, SUM(amount) AS total_amount')
->group('user_id')
->havingRaw('SUM(amount) > ?', [1000])
->select();

fieldRaw / havingRaw / whereRaw / orderRaw 这一族方法原样透传,不过 parseKey,是构造器场景下的安全通道。

3. count() 的替代:先查 ID 再 PHP 侧统计

1
2
$ids = Db::name('user')->where('status', 1)->column('id');
$count = count($ids);

数据量大时不如方案 1,但小表场景完全够用,且零 SQL 风险。

建议的修复方向

根治要在驱动层:parseKey 增加表达式识别——输入中包含 (、)、+、-、*、|| 等字符,或匹配 AS xxx 结尾时,视为表达式不加引号(必要时只给其中的裸标识符加引号)。向上游提 PR 时注意兼容 JSON -> 操作符和数组下标的既有逻辑。

经验总结

场景安全写法风险写法
单表计数Db::query() 参数化->count()
分组聚合fieldRaw / havingRawfield('SUM(x) as y')
排序表达式orderRaworder('SUM(x)')
普通等值查询where('field', $v)——(无风险)

ORM 的便利性永远伴随着”它帮你做了什么你不知道”的风险。涉及聚合、表达式、别名的查询,养成用 fetchSql() 看一眼生成 SQL 的习惯,能避开这一类坑的绝大多数。