关联关系

模型通过定义关联方法描述表与表之间的关系:一对一(HasOne)、一对多(HasMany)与多对多(BelongsToMany)。关联数据必须通过 with() 预加载(Eager Loading)后才能访问——框架不提供懒加载(Lazy Loading)。

本文依据框架源码 src/Database/Model.phpsrc/Database/Model/{Query,RelationQuery,BelongsToMany,InteractsWithPivot}.php 编写。

定义关联

在模型中定义 public 方法返回关联实例(with() 预加载会直接调用该方法):

php
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');
    }
}

关联方法签名与推断规则

php
// 一对一 / 一对多(签名一致,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当前模型的关联键
php
// 多对多(经中间表)
$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_idrole_id)。中间表通常无业务语义,传表名字符串时框架动态创建匿名模型承载,并继承当前模型的数据库通道配置;需要操作中间表扩展字段时也可传入自定义模型。

预加载(with)

php
// 单个关联
$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 提供自动绑定外键的写入与删除:

php
// 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(intstring
delete`delete(intstring

多对多(中间表绑定)

多对多的写入操作作用在中间表上,由 InteractsWithPivot 提供:

php
$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(intstring
detach`detach(intstring
sync`sync(intstring

多对多不支持 create/delete

在多对多关联上调用 create() / delete() 会抛出 InvalidArgumentException(继承实现会错误地把中间表外键写进关联表)。新增绑定用 attach(),解除绑定用 detach()。并发 attach 时建议为中间表 (foreign, related) 建唯一索引兜底。

中间表条件(wherePivot)

wherePivot() 过滤的是绑定关系本身(中间表行),区别于 with() 闭包(过滤关联模型数据)。条件仅作用于读侧预加载,不影响 attach() / detach() 的写入范围:

php
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 键,保存对应的中间表行(数组),可读取绑定时间等扩展字段:

php
$user = UserModel::with('roles')->find(1);

foreach ($user->roles as $role) {
    echo $role->name;                 // 角色名称(roles 表)
    echo $role->pivot['bind_time'];   // 绑定时间(role_user 中间表)
}

常见替代方案

框架未提供 has()withCount(),可使用查询构造器等价实现:

php
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();

完整示例

php
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}";
}

下一步