リレーション
Weaver ORM は、RelationMap を介してエンティティのマッパークラス内にすべてのリレーションメタデータを定義します。エンティティプロパティへのアトリビュートはなく、実行時リフレクションもありません。リレーションは常に明示的にロードされます — Weaver は裏でサプライズクエリを発行することはありません。
概要
オーナー側と逆側
すべてのリレーションには1つのオーナー側と1つの逆側があります。
- オーナー側はそのテーブル(または多対多の場合はピボットテーブル)に外部キーを持ちます。関連付けの永続化を制御します。
- 逆側は
mappedByでオーナー側を指し示して宣言されます。逆側のみに加えた変更はデータベースに書き込まれません。
外部キーの配置ル ール
| リレーション型 | FK の場所 | マッパーメソッド |
|---|---|---|
| 1対1 | 「他の」エンティティのテーブル | hasOne |
| 1対多 | 「多」エンティティのテーブル | hasMany |
| 多対1 | このエンティティのテーブル | belongsTo |
| 多対多 | 専用のピボットテーブル | belongsToMany |
| ポリモーフィック1対1 | モーファブルエンティティのテーブル | morphOne |
| ポリモーフィック1対多 | モーファブルエンティティのテーブル | 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(1対1)
HasOne は、外部キーが他のエンティティのテーブルにある1対1のリレーションシップを表します。User は1つの 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 テ ーブルのカラム(PK)
->mappedBy('user'); // Profile の逆側プロパティ名
}
// src/Mapper/ProfileMapper.php
protected function relations(RelationMap $map): void
{
$map->belongsTo('user', User::class)
->foreignKey('user_id')
->ownerKey('id');
}
イーガーロード:
// 追加の IN クエリ1回 — N+1 なし
$user = $repository->findById(1, with: ['profile']);
echo $user->profile?->bio;
// 多数のユーザーのプロファイルを一括ロード(単一の 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: 'ソフトウェアエンジニア');
$em->persist($user, cascade: [CascadeType::Persist]);
$em->flush();
// まず users 行を挿入し、次に正しい user_id で profiles 行を挿入
HasMany(1対多)
HasMany は、外部キーが多側にある1対多のリレーションシップを表します。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); // コレクションから削除された投稿を削除
}
コレクションの操作:
// イーガーロード
$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'); // post.id でキー付けされた EntityCollection
$post = $user->posts->get(42);
BelongsTo(多対1)
BelongsTo は、外部キーがこのエンティティのテーブルにある多対1のリレーションシップを表します。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 テーブルの PK
}
オプション(nullable)FK — オーナーなしのゲストコメント:
$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 は、ピボ ット(ジャンクション)テーブルに支えられた多対多のリレーションシップを表します。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') // このエンティティを指す FK
->relatedPivotKey('tag_id') // 関連エンティティを指す FK
->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 . ' — ロール: ' . $pivot->get('role');
}
ピボットテーブルの管理:
// ピボットデータと共に1つのタグをアタッチ
$em->relation($post, 'tags')->attach(tagId: 5, pivot: ['role' => 'primary']);
// 複数をアタッチ
$em->relation($post, 'tags')->attach([
5 => ['role' => 'primary'],
8 => ['role' => 'secondary'],
]);
// 1つをデタッチ
$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);