关联查询

Viswoole ORM 支持定义模型之间的关联关系,包括一对一(HasOne)、一对多(HasMany)、多对多(BelongsToMany)等常见关系类型。关联数据必须通过 with() 预加载后才能访问。

一对一 (HasOne)

一对一关联表示一个模型拥有另一个模型的单条关联记录,例如一个用户对应一份个人资料。

定义关联

php
namespace App\Model;

use Viswoole\Database\Model;
use Viswoole\Database\Model\RelationQuery;

class UsersModel extends Model
{
    protected string $table = 'users';
    protected string $pk = 'id';

    /**
     * 用户个人资料(一对一)
     *
     * @return RelationQuery
     */
    public function profile(): RelationQuery
    {
        // hasOne(关联模型类, 外键字段名)
        return $this->hasOne(ProfileModel::class, 'user_id');
    }
}

class ProfileModel extends Model
{
    protected string $table = 'profiles';
    protected string $pk = 'id';
}

使用关联

注意:框架不提供懒加载(延迟加载)。关联数据必须通过 with() 预加载后才能通过属性访问,未预加载的关联属性返回 null

php
// 必须先通过 with() 预加载关联数据
$user = UsersModel::with(['profile'])->find(1);

echo $user->profile->bio;      // 访问关联模型的字段
echo $user->profile->avatar;   // 个人头像

数据表示例:

text
users 表          profiles 表
┌────┬──────┐    ┌────────┬─────────┬──────────┐
│ id │ name │    │ id     │ user_id │ bio      │
├────┼──────┤    ├────────┼─────────┼──────────┤
│ 1  │ 张三 │───▶│ 1      │ 1       │ PHP开发者│
│ 2  │ 李四 │───▶│ 2      │ 2       │ 设计师   │
└────┴──────┘    └────────┴─────────┴──────────┘

一对多 (HasMany)

一对多关联表示一个模型拥有多条关联记录,例如一个用户可以拥有多个订单。

定义关联

php
class UsersModel extends Model
{
    protected string $table = 'users';
    protected string $pk = 'id';

    /**
     * 用户角色(一对多)
     *
     * @return RelationQuery
     */
    public function roles(): RelationQuery
    {
        // hasMany(关联模型类, 外键字段名)
        return $this->hasMany(UserRoles::class, 'user_id');
    }

    /**
     * 用户订单(一对多)
     *
     * @return RelationQuery
     */
    public function orders(): RelationQuery
    {
        return $this->hasMany(OrderModel::class, 'user_id');
    }
}

class UserRoles extends Model
{
    protected string $table = 'user_roles';
    protected string $pk = 'id';
}

class OrderModel extends Model
{
    protected string $table = 'orders';
    protected string $pk = 'id';
}

使用关联

php
// 必须先通过 with() 预加载关联数据
$user = UsersModel::with(['roles', 'orders'])->find(1);

// 获取用户的所有角色(集合)
foreach ($user->roles as $role) {
    echo $role->role_name;
}

// 获取用户的所有订单
foreach ($user->orders as $order) {
    echo "订单号: {$order->order_no}, 金额: {$order->amount}";
}

// 统计关联数量
echo "角色数: " . count($user->roles);
echo "订单数: " . count($user->orders);

数据表示例:

text
users 表              user_roles 表
┌────┬──────┐         ┌────┬─────────┬───────────┐
│ id │ name │         │ id │ user_id │ role_name │
├────┼──────┤         ├────┼─────────┼───────────┤
│ 1  │ 张三 │────┬───▶│ 1  │ 1       │ admin     │
│ 2  │ 李四 │     ├──▶│ 2  │ 1       │ editor    │
└────┴──────┘     └───▶│ 3  │ 2       │ viewer    │
                  ┌───▶│ 4  │ 2       │ guest     │
                  │    └────┴─────────┴───────────┘


               orders 表
               ┌────┬─────────┬───────┐
               │ id │ user_id │ amount│
               ├────┼─────────┼───────┤
               │ 1  │ 1       │ 99.00 │
               │ 2  │ 1       │ 199.00│
               │ 3  │ 2       │ 50.00 │
               └────┴─────────┴───────┘

多对多 (BelongsToMany)

多对多关联通过**中间表(pivot)**关联两个模型,例如用户与角色通过 role_user 中间表关联。belongsToMany() 方法签名:

php
protected function belongsToMany(
    Model|string $relationModel,     // 关联模型类名或实例
    Model|string|null $pivot = null, // 中间表模型类名、实例或表名
    ?string $foreignPivotKey = null, // 中间表中指向当前模型的外键名
    ?string $relatedPivotKey = null, // 中间表中指向关联模型的外键名
    ?string $localKey = null,        // 当前模型的关联键名(默认 $pk)
    ?string $relatedKey = null       // 关联模型的关联键名(默认关联模型 $pk)
): BelongsToMany

定义关联

php
namespace App\Model;

use Viswoole\Database\Model;
use Viswoole\Database\Model\BelongsToMany;

class UsersModel extends Model
{
    protected string $table = 'users';
    protected string $pk = 'id';

    /**
     * 用户角色(多对多)
     *
     * @return BelongsToMany
     */
    public function roles(): BelongsToMany
    {
        // 方式一:全默认推断
        // 中间表名: users_roles,外键: users_id / roles_id
        // return $this->belongsToMany(RoleModel::class);

        // 方式二:指定中间表名与外键(推荐,表名明确)
        return $this->belongsToMany(RoleModel::class, 'role_user', 'user_id', 'role_id');

        // 方式三:中间表传模型实例(需要自定义中间表模型时使用)
        // return $this->belongsToMany(RoleModel::class, RoleUserModel::class, 'user_id', 'role_id');
    }
}

默认推断规则(与 hasOne/hasMany 一致):

  • 中间表名:{当前表名}_{关联表名}(如 users_roles
  • 外键名:{表名}_{主键名}(如 users_idroles_id
  • 中间表通常无业务语义,框架会动态创建匿名中间表模型并继承当前模型的数据库通道配置

使用关联

与 hasOne/hasMany 一样,多对多关联也必须通过 with() 预加载。结果为 Collection 集合,每条关联数据通过 pivot 键附带对应的中间表行数据(可读取绑定时间等扩展字段):

php
// 必须先通过 with() 预加载关联数据
$user = UsersModel::with(['roles'])->find(1);

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

数据表示例:

text
users 表               role_user 中间表         roles 表
┌────┬──────┐         ┌────────┬─────────┬────────────┐  ┌────┬────────┐
│ id │ name │         │ user_id│ role_id │ bind_time  │  │ id │ name   │
├────┼──────┤         ├────────┼─────────┼────────────┤  ├────┼────────┤
│ 1  │ 张三 │───┐     │ 1      │ 10      │ 2026-01-01 │──▶│ 10 │ admin  │
│ 2  │ 李四 │───┤     │ 1      │ 11      │ 2026-01-02 │──▶│ 11 │ editor │
└────┴──────┘   │     │ 2      │ 10      │ 2026-01-05 │  └────┴────────┘
                └───▶ │ 2      │ 12      │ 2026-02-01 │
                      └────────┴─────────┴────────────┘

wherePivot — 中间表条件过滤

wherePivot() 用于在查询绑定关系时过滤中间表数据(区别于 with() 闭包中的条件,后者过滤的是关联模型数据,如角色被禁用):

php
class UsersModel extends Model
{
    /**
     * 仅取 2026-01-05 之后建立的绑定
     *
     * @return BelongsToMany
     */
    public function recentRoles(): BelongsToMany
    {
        return $this->belongsToMany(RoleModel::class, 'role_user', 'user_id', 'role_id')
            ->wherePivot('bind_time', '>=', '2026-01-05');
    }

    /**
     * 仅取指定状态的绑定(两参简写:数组自动转 IN 条件)
     *
     * @return BelongsToMany
     */
    public function enabledRoles(): BelongsToMany
    {
        return $this->belongsToMany(RoleModel::class, 'role_user', 'user_id', 'role_id')
            ->wherePivot('status', [1, 2]);
    }
}

attach — 新增绑定

attach() 为当前主数据新增绑定关系(幂等操作:已存在的绑定自动跳过)。可通过第三个参数附加中间表扩展字段:

php
// 关联方法需在模型实例上调用
$user = new UsersModel();

// 为 1 号用户绑定角色 10、11,并写入绑定时间
$user->roles()->attach(1, [10, 11], ['bind_time' => date('Y-m-d H:i:s')]);

// 单个关联键可直接传标量;$parent 也可传主表行数据集(如 find() 查询结果)
$row = UsersModel::find(1);
$user->roles()->attach($row, 10);

detach — 解除绑定

php
$user = new UsersModel();

// 解除指定绑定(标量或数组均可)
$user->roles()->detach(1, 10);
$user->roles()->detach(1, [10, 11]);

// 解除该用户的全部绑定
$user->roles()->detach(1);

sync — 同步绑定(多退少补)

sync() 以目标关联键集合为准对齐绑定:新增缺失的绑定、移除多余的绑定,交集保持不变。返回新增与移除的关联键列表:

php
$user = new UsersModel();

// 将 1 号用户的角色对齐为 10、20:解绑 11,新增 20
$result = $user->roles()->sync(1, [10, 20], ['bind_time' => '2026-03-01']);

// 结果
// ['attached' => [20], 'detached' => [11]]

// 目标为空数组:清空该用户的全部绑定(等价 detach($parent))
$result = $user->roles()->sync(1, []);
// ['attached' => [], 'detached' => [10, 11]]

注意:多对多关联不支持create()delete() 方法(调用会抛出异常)。新增绑定请使用 attach(),解除绑定请使用 detach()

预加载 (Eager Loading)

预加载用于一次性取出模型的关联数据。框架不做懒加载,未预加载的关联属性返回 null,因此必须先通过 with() 预加载关联数据后才能访问。

不使用预加载的情况

php
// ❌ 不使用预加载 — 无法访问关联数据(框架不做懒加载)
$users = UsersModel::select();           // 第 1 次查询:获取所有用户

foreach ($users as $user) {
    echo $user->profile->bio;            // 未预加载时 $user->profile 为 null,取不到关联数据
}

解决方案:with 预加载

php
// ✅ 使用预加载 — 仅 2 次查询
$users = UsersModel::with(['profile'])->select();
// 第 1 次:SELECT * FROM users
// 第 2 次:SELECT * FROM profiles WHERE user_id IN (1,2,3,...)

foreach ($users as $user) {
    echo $user->profile->bio;            // 无额外查询
}

预加载多个关联

php
// 同时预加载多个关联关系
$users = UsersModel::with(['profile', 'roles', 'orders'])->select();

foreach ($users as $user) {
    echo $user->profile->bio;      // 已预加载
    foreach ($user->roles as $role) { // 已预加载
        echo $role->role_name;
    }
}

嵌套预加载

注意:当前版本不支持点号语法的嵌套预加载(如 with(['orders.items']))。with() 方法会检查关联方法是否存在,orders.items 会被当作方法名查找从而抛出异常。如需加载多层关联,请分别在各模型中调用 with()

带条件的预加载

对预加载的关联添加约束条件:

php
$users = UsersModel::with(['orders' => function ($query) {
    // 只预加载近 30 天的订单
    $query->where('create_time', '>=', date('Y-m-d', strtotime('-30 days')))
          ->orderBy('create_time', 'desc');
}])->select();

关联查询进阶

关联条件筛选

注意:当前版本未提供 has()withCount() 方法。如需基于关联关系筛选或统计关联数量,可通过子查询或 whereExists 等方式实现。

例如,查询有订单的用户可使用 whereExists

php
// 查询有订单的用户
$users = UsersModel::whereExists(
    'SELECT 1 FROM orders WHERE orders.user_id = users.id'
)->select();

如需统计关联数量,可使用 leftJoin 结合 groupBy 聚合查询:

php
use Viswoole\Database\Facade\Db;

$users = Db::table('users')
    ->columns('users.*', 'COUNT(orders.id) as orders_count')
    ->join('orders', 'users.id', 'orders.user_id')
    ->groupBy('users.id')
    ->getArray();

foreach ($users as $user) {
    echo "{$user['name']} 的订单数: {$user['orders_count']}";
}

完整示例

php
namespace App\Model;

use Viswoole\Database\Facade\Db;
use Viswoole\Database\Model;
use Viswoole\Database\Model\RelationQuery;

/**
 * 用户模型 — 含完整关联定义
 */
class UsersModel extends Model
{
    protected string $table = 'users';
    protected string $pk = 'id';
    protected int $autoWriteTimestamp = 1;
    protected bool $enableSoftDelete = true;
    protected array $hidden = ['password'];

    /**
     * 个人资料(一对一)
     *
     * @return RelationQuery
     */
    public function profile(): RelationQuery
    {
        return $this->hasOne(ProfileModel::class, 'user_id');
    }

    /**
     * 用户角色(一对多)
     *
     * @return RelationQuery
     */
    public function roles(): RelationQuery
    {
        return $this->hasMany(UserRoles::class, 'user_id');
    }

    /**
     * 用户订单(一对多)
     *
     * @return RelationQuery
     */
    public function orders(): RelationQuery
    {
        return $this->hasMany(OrderModel::class, 'user_id');
    }
}

// ============================================
// 控制器中使用
// ============================================

// 获取用户详情(含关联数据)
public function show(int $id): array
{
    $user = UsersModel::with(['profile', 'roles'])
        ->find($id);

    // find() 返回 DataSet,空结果为空 DataSet(非 null),需用 isEmpty() 判断
    if ($user->isEmpty()) {
        throw new NotFoundException('用户不存在');
    }

    return $user->toArray();
}

// 用户列表(含订单计数 — 使用 join 聚合替代 withCount)
public function index(): array
{
    return Db::table('users')
        ->columns('users.*', 'COUNT(orders.id) as orders_count')
        ->join('orders', 'users.id', 'orders.user_id')
        ->where('users.status', 1)
        ->groupBy('users.id')
        ->orderBy('users.create_time', 'desc')
        ->page(1, 20)
        ->getArray();
}