上周帮团队扫清一个老项目迁移卡点:ThinkPHP 5.1 控制器里埋着 27 处 Db::query()Db::execute(),混着字符串拼接、sprintf、甚至裸 query("SELECT * FROM user WHERE id = $id")(没错,它还在)。手动改 Eloquent?光是 WHERE 条件里那堆 " AND status = %d AND type IN (%s)" 就够人盯三小时。

这些 SQL 不是写在模型或 Service 里,就钉死在控制器 action 里——没抽象、没测试、没文档。有人试过正则替换,结果把 "SELECT * FROM user WHERE id = ?" 里的 user 当成表名替成了 User::where('id', $id)->first(),而实际那个 user 是个视图名,带 JOIN 和子查询。Rector 的价值不在“快”,而在“不碰错 AST 节点”:它能区分 Db::table('user')Db::query("SELECT * FROM user"),也能识别 ->where('status', 1) 后面是否跟了 ->limit(10),再决定生成 first() 还是 get()。这不是语法糖搬运工,是 AST 层的语义翻译器。

从零搭一个 Rector 规则,没你想的那么玄

别被“AST”吓住——Rector 的自定义规则,本质就是 PHP 类:实现 Rector\Contract\Rector\RectorInterface,写清两个方法:getRuleDefinition()refactor()。它不跑运行时,只读 AST 节点,改完直接吐出新 PHP 文件。先装本体:。接着初始化配置:,生成 rector.php。注意:别用 --with-docker--level,我们不走预设规则集,要的是白板起步。

新建

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use Rector\Contract\Rector\RectorInterface;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

final class DbQueryToEloquentRector extends AbstractRector implements RectorInterface
{
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition('Convert Db::query() to Eloquent', []);
    }

    public function getNodeTypes(): array
    {
        return [MethodCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        if (! $this->isName($node->name, 'query') || ! $this->isObjectType($node->var, 'think\Db')) {
            return null;
        }

        // 简单起见,先硬编码替换为 User::all()
        return $this->nodeFactory->createStaticCall('App\Models\User', 'all');
    }
}

规则注册进 rector.php$services->set(DbQueryToEloquentRector::class);。跑一次 ,就能看到效果。这玩意儿没魔法,只有节点匹配 + 构造新节点。下一步才是啃硬骨头:解析 SQL 字符串、提取表名、WHERE 条件、ORDER BY……但至少,你已经让 Rector 认得清 Db::query() 了。

Rector custom rule PHP code AST transformation

硬啃 SQL 解析:从 User::all() 到真正的链式调用

硬编码 User::all() 只是热身。真要落地,得拆开 SQL 字符串——不是正则硬切,而是用 (v4.10.0)解析 AST。它能把 "SELECT * FROM users WHERE id = ? AND status = ? ORDER BY created_at DESC" 拆成表名、条件数组、排序字段,连参数占位符位置都标得清清楚楚。表名和模型映射不能靠猜。我们加了个小映射表:['users' => 'App\Models\User', 'posts' => 'App\Models\Post']。不走自动反射,也不依赖注释;就靠配置。遇到没配的表?跳过,留 warning 日志——宁可漏改,不瞎改。

WHERE 条件得转成 where() 链。参数绑定顺序必须对齐。SQL 里 WHERE id = ? AND name LIKE ? 对应 ->where('id', $params[0])->where('name', 'like', $params[1])。别忘了 LIKE 要显式传第三个操作符参数,Eloquent 不吃默认。

return $this->nodeFactory->createStaticCall(
    $modelClass,
    'where',
    [$this->nodeFactory->createString($column), $this->nodeFactory->createString($operator), $paramNode]
)->methodCall('get');

这步做完,Db::query("SELECT * FROM users WHERE id = ?", [123]) 就真能变成可运行的 Eloquent 链了。调试时多打两行 dump($parsedSql),比读文档快十倍。

Db query to Eloquent chain call conversion

JOIN、子查询和参数绑定的坑,一个没少踩

单表查询只是开胃菜。ThinkPHP 遗留代码里最让人头疼的,是那种几十行拼出来的 JOIN 大 SQL,参数占位符穿插在表名和条件之间。Db::query("SELECT u.*, p.title FROM users u LEFT JOIN posts p ON u.id = p.user_id WHERE u.status = ? AND p.created_at > ?", [1, '2023-01-01'])——粗看能跑,但扔给 Eloquent 就傻眼了。我们得让 Rector 认得 LEFT JOIN、INNER JOIN,甚至嵌套的子查询。SqlParser 的 AST 会把 JOIN 子句单独拆成一张表:JoinItem 节点。遍历它,拿到关联表名、ON 条件,然后拼出 ->leftJoin('posts', 'users.id', '=', 'posts.user_id')。别忘了 join 的三个参数顺序:Eloquent 的 join() 默认第一个是表名,第二个是第一个字段,第三个是操作符,第四个是第二个字段。跟 SQL 的书写顺序不太一样,我第一次写反了,跑了二十条测试才发现。

$joinExpr = $joinItem->getJoinExpression();
$tableName = $joinItem->getTable()->getName();
$leftExpr = $joinExpr->getLeft()->__toString();
$rightExpr = $joinExpr->getRight()->__toString();
$onParts = explode('=', $joinExpr->__toString()); // 偷懒做法,线上用 AST 的 Expression 节点更稳
$this->addMethodCall('join', [$tableName, trim($onParts[0]), '=', trim($onParts[1])]);

子查询更麻烦。比如 SELECT * FROM users WHERE id IN (SELECT user_id FROM posts WHERE status = ?)。SqlParser 的 Subquery 节点返回的是一个完整的 Select 对象。我的做法是:递归解析子查询,生成一个独立的 DB::raw() 调用,然后塞回父查询的 whereIn 闭包里。看起来像这样:

$subQuery = $this->parseSelectToEloquent($subSelect, $subParams);
return $this->nodeFactory->createStaticCall('DB', 'raw', [$subQuery]);

参数绑定是另一个坑。原始 SQL 里全是问号占位符,解析 AST 时,每个问号对应一个 Param 节点。我们得按顺序从参数数组中取值,生成 ParamNode。关键点:LIKE 的操作符不能省略,IN 要展开成数组参数,BETWEEN 要拆成两个参数。测试时发现 WHERE name LIKE '%?%' 这种模式——百分号在占位符两侧——需要把字符串拼接逻辑提前,否则 Eloquent 会把百分号当作原始字符串。调试经验:写个辅助函数,把解析后的 AST 结构打印成树状图。我习惯在 dump($parsedSql->getStatements()) 里 dump 整个 AST,一眼就能看出 JOIN 节点的层级。比对着文档猜结构快多了。

最后一个小提醒:LEFT JOINRIGHT JOIN 的转换别搞混。Rector 的 NodeFactory 里没有现成的 rightJoin,得自己补一个。我曾因为偷懒直接用了 join,结果查询结果少了半壁江山。那两天,加班改 bug 改到凌晨两点。做完这些,Db::query 里的 JOIN 和子查询就能自动变成 Eloquent 链了。剩下的是表别名——AS u 这种,我选择直接忽略,让 Eloquent 生成默认别名。反正跑得通,别纠结。

测试这关不过,规则就是定时炸弹

写完规则不等于能上生产。我第一次在客户项目里跑 --dry-run,发现 WHERE id = ? AND status IN (?) 被转成了 where('id', $params[0])->whereIn('status', [$params[1]])——但实际传参是 ['1', 'active'],而 IN 期望数组,结果查出空集。PHPUnit 测试必须覆盖参数类型、占位符位置、嵌套层级这三类边界。我建了 ,用 Rector 的 Rector\Testing\PHPUnit\AbstractRectorTestCase 基类。重点不是测「有没有改」,而是测「改得对不对」:比如原始 SQL 含 ORDER BY FIELD(id, 3,1,2),得断言生成的 orderByRaw 正确包裹了反引号;LIKE 模糊匹配的 % 必须保留在字符串里,不能进 bindParam

.github/workflows/rector.yml 里,我把 加进 PR 检查。失败就阻断合并。没人敢绕过——上次有人手动删了测试用例,CI 立刻报错:「Db::query() → DB::table()->where() 转换丢失子查询闭包」。

参考与延伸阅读

代码写完,跑一遍 —— 看着那 27 处原生 SQL 变成优雅的 Eloquent 链,总算能踏实合上电脑了。