上个月给一个报表系统做数据筛选功能,产品经理一口气扔过来十几个筛选项,什么时间范围、地区、渠道、用户等级、是否首次购买……我一看这接口,要是用原生SQL拼where,估计得在代码里写出一座长城。后来用ThinkPHP8的查询构造器重写了一遍,整个人神清气爽。今天抽空把这套经验理一理,专治各种复杂条件查询。
先说说为什么我放弃原生SQL
项目刚起步时图省事,直接写Db::query("select * from orders where ...")。等到条件一多,各种拼接、转义、类型判断,代码乱成一锅粥。而且一旦字段名或表结构变更,改起来想哭。ThinkPHP8的查询构造器就是一个帮我们安全构建SQL的利器,它把条件组装、参数绑定、字段引用都封装好了,写起来舒服且不容易出SQL注入。
就拿最常见的动态条件来说,以前我要这么干:
// 拼接SQL的时代
$sql = "SELECT * FROM orders WHERE status = 1";
if ($start) $sql .= " AND create_time >= '$start'";
if ($end) $sql .= " AND create_time <= '$end'";
$data = Db::query($sql);
又丑又危险。用TP8的构造器直接链式写:
$query = Db::name('orders')->where('status', 1);
if ($start) $query->where('create_time', '>=', $start);
if ($end) $query->where('create_time', '<=', $end);
$data = $query->select();
是不是瞬间清爽了?但这只是开胃菜,真正让我觉得它强大的是后面这几个场景。
动态条件:用when方法代替一堆if
条件一多,if判断满天飞也难受。TP8的查询构造器提供了一个when()方法,可以像写三目表达式一样处理条件分支。比如按时间范围和渠道筛选:
$list = Db::name('orders')
->when($start, function ($query) use ($start) {
$query->where('create_time', '>=', $start);
})
->when($end, function ($query) use ($end) {
$query->where('create_time', '<=', $end);
})
->when($channel, function ($query) use ($channel) {
$query->where('channel', $channel);
}, function ($query) {
// 当 $channel 为空时执行这个回调,可以放默认条件
$query->where('channel', 'organic');
})
->select();
when() 第一个参数是条件,如果是真值就执行第二个回调,如果是假值且提供第三个参数就执行第三个回调。这样把条件分支嵌在链式调用里,代码逻辑一眼就能看穿,而且不用写一堆临时变量。
不过用when()时注意别把复杂的闭包写太长,不然可读性也会下降。我的习惯是:两三个条件用if,四五个以上用when,再多就拆方法。
复杂条件到底怎么盖?闭包子查询来帮忙
有些查询需要嵌套括号,比如:找出“最近30天有订单的用户”或者“订单金额大于平均值的用户”。直接用where方法不好拼,TP8里可以用闭包来构造子查询条件。
举个实际例子:查所有“已经下单但还没发货”的VIP用户,并且他们的注册时间早于某个时间点。这个“已经下单”其实是一个exists子查询:
$list = Db::name('users')
->where('vip', 1)
->where('register_time', '<', '2024-01-01')
->where(function ($query) {
// 闭包里是一个完整的子查询
$query->whereExists(function ($sub) {
$sub->table('orders')
->whereRaw('orders.user_id = users.id')
->where('orders.status', 'pending');
});
})
->select();
注意这里的whereExists,它生成的SQL是:
WHERE vip = 1 AND register_time < '2024-01-01'
AND EXISTS (SELECT * FROM orders WHERE orders.user_id = users.id AND orders.status = 'pending')
看到了吗?构造器自动处理了表名和字段名的引用,连反引号都不用自己加。你可能会问,这样写跟直接用whereRaw有什么区别?好处是:如果以后订单表改名了,只要维护table('orders')一处就行;而且查询构造器内部的参数绑定是安全的,不用手动拼接值。
如果你更习惯用表达式,也可以这样:
->whereExp('id', 'IN (SELECT user_id FROM orders WHERE amount > 100)')
但个人推荐用闭包子查询,因为结构更清晰,能复用一些查询片段。
JSON字段查询:MySQL 5.7+ 的福音
现在的表结构越设计越灵活,有些扩展信息直接存JSON字段。TP8的查询构造器对JSON字段查询支持得不错,以前要写JSON_EXTRACT函数,现在可以用数组方式直接查。
假设我们的users表有一个extra_info字段,存的是JSON,里面包含payment_method和member_level。现在要查所有使用微信支付且会员等级是gold的用户:
$list = Db::name('users')
->where('extra_info->payment_method', 'wechat')
->where('extra_info->member_level', 'gold')
->select();
这个语法是不是看起来像读出数组下标?TP8在底层会自动转成JSON_EXTRACT。甚至你可以在JSON字段上做模糊查找:
->where('extra_info->nickname', 'like', '%小明%')
唯一的坑是:如果字段值里有特殊字符,比如点号,可能会干扰解析。不过大多数场景够用了。
如果你用的是PostgreSQL或者SQLite,TP8也支持类似语法,只是数据库函数不同,构造器帮你做了适配。
一天没用就难受的字段递增/递减
做秒杀扣库存的时候,新手都会写出这样的代码:
$stock = Db::name('products')->where('id', 1)->value('stock');
Db::name('products')->where('id', 1)->update(['stock' => $stock - 1]);
如果并发高一点,超卖就发生了。用查询构造器的inc()方法可以直接在数据库层原子自增:
Db::name('products')->where('id', 1)->inc('stock')->update();
官方写法还有dec(),支持步长和额外条件:
Db::name('products')->where('id', 1)->dec('stock', 2)->update();
减库存的时候顺便加销量:
$query = Db::name('products');
$query->where('id', 1)->dec('stock', 1)->inc('sales', 1)->update();
既有原子性,又省了查改两步,效率也高。
字段组合查询:whereField与Raw表达式
有些复杂条件用常规方法不好表示,比如要比较两个字段的大于小于:查“商品价格高于平均价”的商品。TP8里可以用whereColumn,或者直接用whereRaw。
Db::name('products')
->where('status', 1)
->whereRaw('price > (SELECT AVG(price) FROM products WHERE category_id = ?)', [$categoryId])
->select();
但更推荐使用whereColumn来比较同一行不同字段之间的关系:
// 查所有库存小于预警值的商品
Db::name('products')->whereColumn('stock', '<', 'warn_stock')->select();
这个功能在做库存预警时非常方便,不用自己拼SQL了。
另外还有一个whereOr配合闭包可以实现灵活的组合同步条件,比如查“VIP用户且余额大于0,或者普通用户且积分大于100”:
$list = Db::name('users')
->where(function ($query) {
$query->where('vip', 1)->where('balance', '>', 0);
})
->whereOr(function ($query) {
$query->where('vip', 0)->where('points', '>', 100);
})
->select();
很多人一遇到复杂的或条件就懵,其实只要用闭包把每组条件包起来,再配合whereOr,逻辑就清晰了。
子查询作为表:从零开始的速度优化
有些查询需要先对一张表做聚合,再和另一张表关联。以前我会创建临时表,现在直接可以在构造器里用闭包定义子查询作为数据源。
比如我们要查“每个分类下销量最高的商品”,这东西用普通group by很难取到想要的记录。可以用子查询先算出每个分类的最大销量,再关联商品表:
$subQuery = Db::name('products')
->field('category_id, MAX(sales) as max_sales')
->group('category_id');
$list = Db::name('products')
->alias('p')
->join([''.$subQuery->buildSql().'' => 's'], 's.category_id = p.category_id AND s.max_sales = p.sales')
->select();
注意这里使用了buildSql()来生成子查询SQL,再把它作为join表。很多初学者觉得这很复杂,其实抽象一下就是:先构造一个子查询对象,然后在主查询里引用它。这种写法既不会把SQL写死,又能让数据库优化器正常工作。
如果你觉得拼join太麻烦,还可以直接用whereIn配合子查询:
$subIds = Db::name('products')
->field('max_sales_product_id')
->table('...')
->buildSql();
Db::name('products')->where('id', 'in', $subIds)->select();
灵活度非常高。
聚合查询和快速分页
报表类页面少不了统计。TP8的查询构造器提供了count()、sum()、avg()等聚合方法,跟where链组合起来很自然。
$totalAmount = Db::name('orders')
->where('pay_time', '>', '2025-01-01')
->sum('amount');
$orderCount = Db::name('orders')
->where('status', 'paid')
->count();
分页更是简单,直接用paginate()方法,它会自动读取当前页码、生成总记录数和总页数,并且返回一个分页对象。前端只要把items和total取出来用就行:
$page = Db::name('orders')
->where('user_id', $userId)
->paginate(15);
return json([
'list' => $page->items(),
'total' => $page->total(),
'page' => $page->currentPage(),
'pages' => $page->lastPage(),
]);
当然如果你要兼容小程序那种每页数量自己传的接口,也可以paginate($limit, false, ['page' => $page]),自己控制。
踩过的一些坑,顺便给你排雷
用查询构造器不等于100%安全,有几点我得提醒一下。
第一,尽量少用whereRaw,要用就把参数用问号占位,别直接拼接变量。我已经习惯将任何用户输入都通过构造器的方法传入,只有极少数像`FIELD(1,2,3)`这种排序逻辑才会用Raw。写Raw时一定注意注入。
第二,当使用where('字段', 'like', '%'.$value.'%')时,如果$value里有百分号或下划线,它们会被当成通配符。如果需要匹配字面量,记得转义:
$value = str_replace(['%', '_'], ['\%', '\_'], $value);
Db::name('products')->where('name', 'like', '%'.$value.'%')->select();
第三,使用whereIn时如果数组为空,TP8会生成一个`IN (NULL)`,结果查不到任何数据,但也不会报错。如果逻辑上“空数组代表无条件”,你需要在传入前做判断:
if (!empty($ids)) {
$query->whereIn('id', $ids);
} else {
$query->where('1', '0'); // 强制查不到数据,或者直接返回空
}
我也见过很多人直接空数组传给whereIn,结果数据被过滤掉了,排查半天才发现是空数组的问题。
第四,JSON字段查询时,如果字段值是数字,数据库类型转换可能影响索引,所以在设计表时别把数字存成字符串。别问我怎么知道的……
总结一下我的使用习惯
现在我在项目里基本不写原生SQL,除非是那种极其复杂的存储过程逻辑。查询构造器已经覆盖了我95%的需求。而且它让代码看起来更像是在“描述业务”,而不是在“拼接字符串”。
如果你还在用老的字符串拼接方式,真心建议花一个下午试试TP8的查询构造器。先把when、闭包、whereColumn这几个常用技巧用熟,再慢慢玩子查询和JSON查询。你会发现,以前觉得很难搞的需求,其实就是几行链式调用的事。
最后要记住,构造器只是一个工具,它不能帮你想清楚业务逻辑。但一旦你想清楚了,它能让你写出来的代码像诗一样干净。

