laravel/framework: PR #61884: Fix recycled models leaking into later calls on the same factory
Contributed by xurshudyan
Part of the framework v13.35.0 release
类型: Bug 修复 · 包: illuminate/database · PR: #61884
Laravel 的 recycle() 本应是不可变的:它返回一个新的工厂实例,而让原始实例保持原样。PR #61884 修复了该契约在嵌套关联中失效的漏洞。如果重用了一个使用 has() 或 hasAttached() 的工厂,一次 recycle() 调用就会将回收的模型永久固化到共享的关联定义中,从而污染该工厂后续的每一次 create() 调用。对于从 v13.34.0 升级的团队来说,这是一个即插即用的 bug 修复,没有公开的 API 变更 —— 你的工厂只是在调用之间不再泄漏状态了。
Bug:不肯放手的回收模型
recycle() 用于告诉工厂在构建关联时重用现有的模型实例,而不是创建新的模型 —— 例如,让新帖子指向你现有的某个团队,而不是生成新的团队。为了支持这一点,Factory::recycle() 会克隆自身并将回收的模型向下传递给每个注册的关联定义(has() 的 Relationship,以及 hasAttached() 的 BelongsToManyRelationship)。
问题出在最后一步。那些关联类通过直接修改自身并将 $this 返回来处理这种移交。由于原始工厂在多次调用中共享这些相同的关联对象,因此这种修改会持续存在:
$users = User::factory()->has(Post::factory()->count(3));
$users->recycle($teamA)->create();
$users->recycle($teamB)->create(); // posts randomly get team A or team B
$users->create(); // posts still get team A or team B
某个给定帖子最终属于哪个回收团队取决于工厂的调用历史,而不是你的意图。即使是不带任何参数调用的 recycle() 也无法“清除”它。
修复了什么
此 PR 使 Relationship::recycle() 和 BelongsToManyRelationship::recycle() 遵循与其他工厂方法相同的不可变性约定。两者现在都会在应用回收模型之前 clone $this,在副本上设置已回收的内部工厂,并返回该新实例 —— 保持原始的共享定义不变:
// Before: mutated the shared relationship in place
$this->factory = $this->factory->recycle($recycle);
return $this;
// After: works on a copy
$relationship = clone $this;
$relationship->factory = $this->factory->recycle($recycle);
return $relationship;
docblock 的返回类型从 @return $this 变更为 @return static,以反映不再返回相同实例的情况。对于 BelongsToManyRelationship,当没有嵌套工厂时(例如,你直接将模型实例传递给 hasAttached()),它仍然可以正确工作。此 PR 包含了一个回归测试 test_recycled_models_do_not_leak_into_later_calls_on_the_same_factory,它精确锁定了这种行为。
修改前与修改后
从框架的测试套件中提取的同样的场景,现在表现得像你可能一直期望的那样:
$factory = PostFactory::new()
->has(CommentFactory::new()->count(2), 'comments');
$factory->recycle($user)->create();
// A fresh, unrelated call on the same factory
$post = $factory->create();
// Before: $post->comments could contain rows authored by $user
// After: clean, as asserted by the new regression test
$this->assertNotContains($user->id, $post->comments->pluck('user_id'));
每次 create() 现在都会从共享定义的原始快照开始;回收的作用域仅限于其自身的调用。
为什么这很重要
这是一种阴险的测试套件污染。工厂通常在 setUp() 中构建一次并在多个测试中重用,因此一个测试中的回收调用可能会静默地改变稍后的测试所创建的数据。症状包括:与顺序相关的失败、依赖于你是否运行整个套件或单个文件而出现间歇性通过的测试,以及指向从未编写的关联逻辑的误导性失败。hasAttached() 用户在使用回收模型进行 belongs-to-many 数据透视表重用时尤其容易受到影响,因为重用的模型直接位于数据透视表中。
升级影响
升级后无需进行代码更改 —— 只需拉取此修复并运行你的测试套件即可。行为修复主要体现在以前出错的情况中。有两个小注意事项:
-
如果你在不知不觉中依赖于这种泄漏(例如,同一个回收模型在多次调用中持续存在),现在可以通过保持已回收的工厂实例来显式实现这一点:
$teamAUsers = User::factory() ->has(Post::factory()->count(3)) ->recycle($teamA); $teamAUsers->create(); $teamAUsers->create(); // both explicitly reuse $teamA -
@return $this→@return static的变更仅影响静态分析;在这些内部对象上进行标识(===)比较从来都不是受支持的模式。
总结
- 什么:
Relationship::recycle()和BelongsToManyRelationship::recycle()现在返回副本而不是修改自身,从而阻止了回收的模型泄漏到同一工厂实例上的后续create()调用中。 - 受影响的群体: 任何在多个调用中重用带有
has()或hasAttached()的工厂并调用recycle()的人。 - 类型: Bug 修复 —— 没有新的 API,没有弃用,没有配置更改。
- 行动: 从 v13.34.0 升级是即插即用的;如果你的测试套件意外通过了,请重新审查任何似乎依赖于该泄漏的测试,并根据需要显式地保持已回收的工厂。 }