关联关系
模型通过定义关联方法描述表与表之间的关系:一对一(HasOne)、一对多(HasMany)与多对多(BelongsToMany)。关联数据必须通过 with() 预加载(Eager Loading)后才能访问——框架不提供懒加载(Lazy Loading)。
本文依据框架源码
src/Database/Model.php与src/Database/Model/{Query,RelationQuery,BelongsToMany,InteractsWithPivot}.php编写。
定义关联
在模型中定义 public 方法返回关联实例(with() 预加载会直接调用该方法):
namespace App\Model;
use Viswoole\Database\Model;
use Viswoole\Database\Model\{BelongsToMany, RelationQuery};
class UserModel extends Model
{
protected string $table = 'user';
// 一对一:一份个人资料
public function profile(): RelationQuery
{
return $this->hasOne(ProfileModel::class, 'user_id');
}
// 一对多:多篇文章
public function articles(): RelationQuery
{
return $this->hasMany(ArticleModel::class, 'user_id');
}
// 多对多:多个角色(经中间表 role_user)
public function roles(): BelongsToMany
{
return $this->belongsToMany(RoleModel::class, 'role_user', 'user_id', 'role_id');
}
}关联方法签名与推断规则
// 一对一 / 一对多(签名一致,hasMany 结果为集合)
$this->hasOne(Model|string $relationModel, ?string $foreignKey = null, ?string $localKey = null): RelationQuery
$this->hasMany(Model|string $relationModel, ?string $foreignKey = null, ?string $localKey = null): RelationQuery| 参数 | 默认推断 | 说明 |
|---|---|---|
$foreignKey | {当前表名}_{当前主键} | 关联表中的外键字段名,如 user_id |
$localKey | 当前模型 $pk | 当前模型的关联键 |
// 多对多(经中间表)
$this->belongsToMany(
Model|string $relationModel, // 关联模型类名或实例
Model|string|null $pivot = null, // 中间表:模型类名/实例/表名,null 按表名推断
?string $foreignPivotKey = null, // 中间表中指向当前模型的外键
?string $relatedPivotKey = null, // 中间表中指向关联模型的外键
?string $localKey = null, // 当前模型关联键(默认 $pk)
?string $relatedKey = null, // 关联模型关联键(默认其 $pk)
): BelongsToMany多对多的推断规则:中间表名 {当前表名}_{关联表名}(如 user_role)、中间表外键 {表名}_{主键}(如 user_id、role_id)。中间表通常无业务语义,传表名字符串时框架动态创建匿名模型承载,并继承当前模型的数据库通道配置;需要操作中间表扩展字段时也可传入自定义模型。
预加载(with)
// 单个关联
$user = UserModel::with('profile')->find(1);
echo $user->profile->bio;
// 多个关联(第 1 次查主表,其余每个关联各 1 次 IN 查询)
$users = UserModel::with(['profile', 'articles'])->select();
// 闭包约束:过滤的是关联模型数据(预加载查询上追加条件)
$users = UserModel::with(['articles' => function ($query) {
$query->where('status', 1)->orderBy('create_time', 'desc');
}])->select();
// 一对多结果为 Collection,一对一为 DataSet
foreach ($users as $user) {
echo $user->profile->bio; // DataSet
foreach ($user->articles as $article) { // Collection
echo $article->title;
}
}不做懒加载
未预加载的关联属性为 null,直接访问取不到数据。框架也不支持 with(['orders.items']) 点号嵌套预加载——嵌套名会被当作模型方法查找而抛出异常,多层关联请在各层模型上分别 with()。
并发预加载
协程环境下,多个关联的查询通过子协程并发执行(非协程环境自动降级为串行),任一关联查询失败会抛出异常并中止整体预加载。
关联写入
一对一 / 一对多
RelationQuery 提供自动绑定外键的写入与删除:
// create:为 1 号用户新增文章,外键 user_id 由框架强制写入
// $parent 可传主键值,也可传主表行数据集(如 UserModel::find(1) 的结果)
$article = (new UserModel())->articles()->create(1, ['title' => '标题']);
// delete:删除 1 号用户的全部文章(关联模型启用软删除时为软删除,$real = true 强制硬删除)
(new UserModel())->articles()->delete(1);| 方法 | 签名 | 说明 |
|---|---|---|
create | `create(int | string |
delete | `delete(int | string |
多对多(中间表绑定)
多对多的写入操作作用在中间表上,由 InteractsWithPivot 提供:
$user = new UserModel();
// attach:新增绑定(幂等,已存在的绑定自动跳过),可附带中间表扩展字段
$user->roles()->attach(1, [10, 11], ['bind_time' => date('Y-m-d H:i:s')]);
// detach:解除绑定,$related 为 null 时解除全部
$user->roles()->detach(1, [10, 11]);
$user->roles()->detach(1);
// sync:以目标集合为准多退少补,返回新增与移除的关联键;内部在同事务中执行
$result = $user->roles()->sync(1, [10, 20], ['bind_time' => '2026-03-01']);
// ['attached' => [20], 'detached' => [11]]| 方法 | 签名 | 返回 |
|---|---|---|
attach | `attach(int | string |
detach | `detach(int | string |
sync | `sync(int | string |
多对多不支持 create/delete
在多对多关联上调用 create() / delete() 会抛出 InvalidArgumentException(继承实现会错误地把中间表外键写进关联表)。新增绑定用 attach(),解除绑定用 detach()。并发 attach 时建议为中间表 (foreign, related) 建唯一索引兜底。
中间表条件(wherePivot)
wherePivot() 过滤的是绑定关系本身(中间表行),区别于 with() 闭包(过滤关联模型数据)。条件仅作用于读侧预加载,不影响 attach() / detach() 的写入范围:
class UserModel extends Model
{
// 仅预加载 2026-01-05 之后建立的绑定
public function recentRoles(): BelongsToMany
{
return $this->belongsToMany(RoleModel::class, 'role_user', 'user_id', 'role_id')
->wherePivot('bind_time', '>=', '2026-01-05');
}
}wherePivot(string $column, string|int|float|array $operator, string|int|float|array|null $value = null, string $connector = 'AND') 的参数语义与查询构造器 where() 一致:两参简写默认 =(数组转 IN),运算符白名单校验见 条件查询。
多对多的 pivot 数据
预加载多对多关联时,每条关联数据附带 pivot 键,保存对应的中间表行(数组),可读取绑定时间等扩展字段:
$user = UserModel::with('roles')->find(1);
foreach ($user->roles as $role) {
echo $role->name; // 角色名称(roles 表)
echo $role->pivot['bind_time']; // 绑定时间(role_user 中间表)
}常见替代方案
框架未提供 has() 与 withCount(),可使用查询构造器等价实现:
use Viswoole\Database\Facade\Db;
// 查询有订单的用户:whereExists
UserModel::whereExists('SELECT 1 FROM orders WHERE orders.user_id = user.id')->select();
// 统计每个用户的订单数:join + groupBy 聚合
Db::table('user')
->columns('user.*', 'COUNT(order.id) AS orders_count')
->join('order', 'user.id', 'order.user_id')
->groupBy('user.id')
->getArray();完整示例
use App\Model\UserModel;
// 用户列表页:预加载角色与文章,一次请求仅 3 条 SQL
$users = UserModel::with(['roles', 'articles'])
->where('status', 1)
->orderBy('id', 'desc')
->page(1, 20)
->select();
foreach ($users as $user) {
// 空外键的行会填充空集合/空数据集,无需判空即可遍历
$roleNames = implode(',', array_column($user->roles->toArray(), 'name'));
echo "{$user->name}:{$roleNames}";
}