ThinkPHP8 查询构造器花样玩法:动态条件、子查询、JSON字段一次说清

2026-08-09 0 489

上个月给一个报表系统做数据筛选功能,产品经理一口气扔过来十几个筛选项,什么时间范围、地区、渠道、用户等级、是否首次购买……我一看这接口,要是用原生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_methodmember_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查询。你会发现,以前觉得很难搞的需求,其实就是几行链式调用的事。

最后要记住,构造器只是一个工具,它不能帮你想清楚业务逻辑。但一旦你想清楚了,它能让你写出来的代码像诗一样干净。

ThinkPHP8 查询构造器花样玩法:动态条件、子查询、JSON字段一次说清
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8 查询构造器花样玩法:动态条件、子查询、JSON字段一次说清 https://www.taomawang.com/server/thinkphp/2516.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务