关联关系
Weaver ORM 通过 RelationMap 在实体的映射器类中定义所有关联元数据。实体属性上没有任何注解,也没有运行时反射。关联关系始终显式加载——Weaver 从不在背后偷偷发出查询。
概览
拥有端与反向端
每个关联都有一个拥有端(owning side)和一个反向端(inverse side)。
- 拥有端在其表中(或多对多的中间表中)保存外键。它控制关联的持久化。
- 反向端通过
mappedBy声明,指向拥有端。仅对反向端所做的更改不会写入数据库。
外键位置规则
| 关联类型 | 外键位置 | 映射器方法 |
|---|---|---|
| 一对一 | 在"另一个"实体的表上 | hasOne |
| 一对多 | 在"多"端实体的表上 | hasMany |
| 多对一 | 在本实体的表上 | belongsTo |
| 多对多 | 专用的中间表 | belongsToMany |
| 多态一对一 | 在可变形实体的表上 | morphOne |
| 多态一对多 | 在可变形实体的表上 | morphMany |
声明关联的方式
关联在映射器的 relations(RelationMap $map) 方法中注册:
protected function relations(RelationMap $map): void
{
$map->hasOne('profile', Profile::class)
->foreignKey('user_id')
->localKey('id');
$map->hasMany('posts', Post::class)
->foreignKey('user_id')
->localKey('id')
->orderBy('created_at', 'DESC');
}
HasOne(一对一)
HasOne 表示一对一关联,外键位于另一个实体的表上。一个 User 拥有一个 Profile;profiles 表保存 user_id。
// src/Entity/User.php
final class User
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $name,
public ?Profile $profile = null,
) {}
}
// src/Entity/Profile.php
final class Profile
{
public function __construct(
public readonly int $id,
public readonly int $userId,
public readonly string $bio,
public ?User $user = null,
) {}
}
映射器(拥有端是 ProfileMapper;UserMapper 持有反向端):
// src/Mapper/UserMapper.php
protected function relations(RelationMap $map): void
{
$map->hasOne('profile', Profile::class)
->foreignKey('user_id') // profiles 表上的列
->localKey('id') // users 表上的列(主键)
->mappedBy('user'); // Profile 上的反向属性名
}
// src/Mapper/ProfileMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsTo('user', User::class)
->foreignKey('user_id')
->ownerKey('id');
}
预加载:
// 额外一条 IN 查询 — 永不 N+1
$user = $repository->findById(1, with: ['profile']);
echo $user->profile?->bio;
// 批量加载多个用户的 profile(单条 IN 查询)
$users = $repository->findAll(with: ['profile']);
级联持久化:
$user = new User(id: 0, email: 'alice@example.com', name: 'Alice');
$profile = new Profile(id: 0, userId: 0, bio: 'Software engineer');
$em->persist($user, cascade: [CascadeType::Persist]);
$em->flush();
// 先插入 users 行,再插入带 正确 user_id 的 profiles 行
HasMany(一对多)
HasMany 表示一对多关联,外键位于多端。一个 User 拥有多个 Post;posts 表保存 user_id。
// src/Entity/User.php
use Weaver\ORM\Collection\EntityCollection;
final class User
{
public function __construct(
public readonly int $id,
public readonly string $email,
public readonly string $name,
/** @var EntityCollection<Post> */
public EntityCollection $posts = new EntityCollection(),
) {}
}
// src/Mapper/UserMapper.php
protected function relations(RelationMap $map): void
{
$map->hasMany('posts', Post::class)
->foreignKey('user_id') // posts 表上的列
->localKey('id') // users 表上的列
->orderBy('created_at', 'DESC') // 默认排序
->orphanRemoval(true); // 删除从集合中移除的 post
}
操作集合:
// 预加载
$user = $repository->findById(1, with: ['posts']);
// 添加
$user->posts->add(new Post(...));
$em->flush(); // INSERT
// 移除(启用 orphanRemoval:自动发出 DELETE)
$user->posts->remove($postToDelete);
$em->flush();
// 内存中过滤
$published = $user->posts->filter(fn(Post $p) => $p->published);
// 不加载数据直接计数
$count = $repository->countRelation($user, 'posts');
按字段为集合建立索引:
$map->hasMany('posts', Post::class)
->foreignKey('user_id')
->indexBy('id'); // EntityCollection 以 post.id 为键
$post = $user->posts->get(42);
BelongsTo(多对一)
BelongsTo 表示多对一关联,外键位于本实体的表上。一个 Post 属于一个 User;posts 表保存 user_id。
// src/Mapper/PostMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsTo('author', User::class)
->foreignKey('user_id') // posts 表(本实体)上的列
->ownerKey('id'); // users 表上的主键
}
可选(可空)外键 — 无归属者的访客评论:
$map->belongsTo('author', User::class)
->foreignKey('user_id')
->ownerKey('id')
->nullable(true);
预加载:
$posts = $postRepository->findAll(with: ['author']);
foreach ($posts as $post) {
echo "{$post->author->name}: {$post->title}";
}
BelongsToMany(多对多)
BelongsToMany 表示由**中间表(Pivot Table)**支撑的多对多关联。一个 Post 可以有多个 Tag;post_tag 表保存两个外键。
// src/Mapper/PostMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsToMany('tags', Tag::class)
->pivotTable('post_tag') // 中间表名
->foreignPivotKey('post_id') // 指向本实体的外键
->relatedPivotKey('tag_id') // 指向关联实体的外键
->withPivot('role', 'joined_at') // 要加载的额外中间列
->withPivotTimestamps() // 在中间表上添加 created_at / updated_at
->orderByPivot('joined_at', 'ASC');
}
访问中间表数据:
$post = $postRepository->findById(1, with: ['tags']);
foreach ($post->tags as $tag) {
$pivot = $tag->pivot();
echo $tag->name . ' — role: ' . $pivot->get('role');
}
管理中间表:
// 附加一个带中间数据的标签
$em->relation($post, 'tags')->attach(tagId: 5, pivot: ['role' => 'primary']);
// 附加多个
$em->relation($post, 'tags')->attach([
5 => ['role' => 'primary'],
8 => ['role' => 'secondary'],
]);
// 分离一个
$em->relation($post, 'tags')->detach(tagId: 5);
// 分离全部
$em->relation($post, 'tags')->detach();
// 同步:替换整个中间集合(分离已移除的,附加新增的)
$em->relation($post, 'tags')->sync([3, 7, 11]);
// 带中间数据的同步
$em->relation($post, 'tags')->sync([
3 => ['role' => 'primary'],
7 => ['role' => 'secondary'],
]);
// 仅添加,从不移除
$em->relation($post, 'tags')->syncWithoutDetaching([15, 16]);
// 切换:不存在则附加,存在则分离
$em->relation($post, 'tags')->toggle(tagId: 5);
MorphOne / MorphMany(多态关联)
多态关联允许单个关联指向多种实体类型。"变形"