如何使用 Doctrine 关联 / 关系
视频教程
你喜欢视频教程吗?查看 Mastering Doctrine Relations 视频教程系列。
主要有 两种 关系/关联类型
ManyToOne
/OneToMany
- 最常见的关系,在数据库中使用外键列映射(例如,
product
表上的category_id
列)。这实际上只有一种关联类型,但从关系的两个不同方面来看。 ManyToMany
- 使用连接表,并且当关系的双方都可以拥有对方的多个时需要(例如,“学生”和“班级”:每个学生都在多个班级中,每个班级都有多个学生)。
首先,你需要确定使用哪种关系。如果关系的双方都包含对方的多个(例如,“学生”和“班级”),则你需要 ManyToMany
关系。否则,你可能需要 ManyToOne
。
提示
还有 OneToOne 关系(例如,一个用户有一个 Profile,反之亦然)。在实践中,使用它类似于 ManyToOne
。
ManyToOne / OneToMany 关联
假设你的应用中的每个产品都恰好属于一个类别。在这种情况下,你需要一个 Category
类,以及一种将 Product
对象与 Category
对象关联起来的方法。
首先创建一个带有 name
字段的 Category
实体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
$ php bin/console make:entity Category
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
这将生成你的新实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Entity/Category.php
namespace App\Entity;
// ...
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private $id;
#[ORM\Column]
private string $name;
// ... getters and setters
}
提示
从 MakerBundle: v1.57.0 开始 - 你可以传递 --with-uuid
或 --with-ulid
给 make:entity
。利用 Symfony 的 Uid 组件,这将生成一个实体,其 id
类型为 Uuid 或 Ulid 而不是 int
。
映射 ManyToOne 关系
在此示例中,每个类别可以与多个产品关联。但是,每个产品只能与一个类别关联。这种关系可以概括为:多个产品对一个类别(或等效地,一个类别对多个产品)。
从 Product
实体的角度来看,这是一种多对一关系。从 Category
实体的角度来看,这是一种一对多关系。
要映射此关系,首先在 Product
类上创建一个带有 ManyToOne
属性的 category
属性。你可以手动执行此操作,也可以使用 make:entity
命令,该命令将询问你有关关系的几个问题。如果你不确定答案,请不要担心!你始终可以稍后更改设置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
$ php bin/console make:entity
Class name of the entity to create or update (e.g. BraveChef):
> Product
New property name (press <return> to stop adding fields):
> category
Field type (enter ? to see all types) [string]:
> relation
What class should this entity be related to?:
> Category
Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
> ManyToOne
Is the Product.category property allowed to be null (nullable)? (yes/no) [yes]:
> no
Do you want to add a new property to Category so that you can access/update
Product objects from it - e.g. $category->getProducts()? (yes/no) [yes]:
> yes
New field name inside Category [products]:
> products
Do you want to automatically delete orphaned App\Entity\Product objects
(orphanRemoval)? (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
>
(press enter again to finish)
这更改了两个实体。首先,它向 Product
实体添加了一个新的 category
属性(以及 getter 和 setter 方法)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// src/Entity/Product.php
namespace App\Entity;
// ...
class Product
{
// ...
#[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')]
private Category $category;
public function getCategory(): ?Category
{
return $this->category;
}
public function setCategory(?Category $category): self
{
$this->category = $category;
return $this;
}
}
此 ManyToOne
映射是必需的。它告诉 Doctrine 使用 product
表上的 category_id
列,将该表中的每条记录与 category
表中的记录关联起来。
接下来,由于一个 Category
对象将与多个 Product
对象关联,make:entity
命令还向 Category
类添加了一个 products
属性,用于保存这些对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// src/Entity/Category.php
namespace App\Entity;
// ...
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
class Category
{
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category')]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
// addProduct() and removeProduct() were also added
}
前面显示的 ManyToOne
映射是必需的。但是,此 OneToMany
是可选的:仅当你想要能够访问与类别相关的产品时才添加它(这是 make:entity
询问你的问题之一)。在此示例中,能够调用 $category->getProducts()
将会很有用。如果你不想要它,那么你也不需要 inversedBy
或 mappedBy
配置。
你的数据库已设置好!现在,像往常一样运行迁移
1 2
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
由于关系,这将在 product
表上创建一个 category_id
外键列。Doctrine 准备好持久化我们的关系了!
保存关联实体
现在你可以在实际操作中看到这段新代码了!想象一下你在控制器内部
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// src/Controller/ProductController.php
namespace App\Controller;
// ...
use App\Entity\Category;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/product', name: 'product')]
public function index(EntityManagerInterface $entityManager): Response
{
$category = new Category();
$category->setName('Computer Peripherals');
$product = new Product();
$product->setName('Keyboard');
$product->setPrice(19.99);
$product->setDescription('Ergonomic and stylish!');
// relates this product to the category
$product->setCategory($category);
$entityManager->persist($category);
$entityManager->persist($product);
$entityManager->flush();
return new Response(
'Saved new product with id: '.$product->getId()
.' and new category with id: '.$category->getId()
);
}
}
当你访问 /product
时,会在 category
和 product
表中都添加一行。新产品的 product.category_id
列被设置为新类别的 id
。Doctrine 为你管理此关系的持久化
如果你是 ORM 的新手,这是最难的概念:你需要停止考虑你的数据库,而只考虑你的对象。你不是将类别的整数 id 设置到 Product
上,而是设置整个 Category
对象。Doctrine 在保存时会处理剩下的事情。
获取关联对象
当你需要获取关联对象时,你的工作流程看起来和以前一样。首先,获取一个 $product
对象,然后访问其相关的 Category
对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Controller/ProductController.php
namespace App\Controller;
use App\Entity\Product;
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->find($id);
// ...
$categoryName = $product->getCategory()->getName();
// ...
}
}
在此示例中,你首先根据产品的 id
查询 Product
对象。这将发出一个查询,仅获取产品数据并水合 $product
。稍后,当你调用 $product->getCategory()->getName()
时,Doctrine 会静默地进行第二个查询,以查找与此 Product
相关的 Category
。它准备 $category
对象并将其返回给你。
重要的是你可以访问产品的相关类别,但直到你请求类别时(即它是“延迟加载的”)才实际检索类别数据。
因为我们映射了可选的 OneToMany
侧,所以你也可以在另一个方向上查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function showProducts(CategoryRepository $categoryRepository, int $id): Response
{
$category = $categoryRepository->find($id);
$products = $category->getProducts();
// ...
}
}
在这种情况下,会发生相同的事情:你首先查询单个 Category
对象。然后,只有当(且如果)你访问产品时,Doctrine 才会进行第二个查询以检索相关的 Product
对象。可以通过添加 JOIN 来避免此额外的查询。
连接关联记录
在上面的示例中,进行了两个查询 - 一个用于原始对象(例如 Category
),另一个用于关联对象(例如 Product
对象)。
提示
请记住,你可以通过 Web 调试工具栏查看请求期间进行的所有查询。
如果你预先知道你需要访问这两个对象,则可以通过在原始查询中发出 join 来避免第二个查询。将以下方法添加到 ProductRepository
类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Repository/ProductRepository.php
// ...
class ProductRepository extends ServiceEntityRepository
{
public function findOneByIdJoinedToCategory(int $productId): ?Product
{
$entityManager = $this->getEntityManager();
$query = $entityManager->createQuery(
'SELECT p, c
FROM App\Entity\Product p
INNER JOIN p.category c
WHERE p.id = :id'
)->setParameter('id', $productId);
return $query->getOneOrNullResult();
}
}
这仍然会返回一个 Product
对象数组。但是现在,当你调用 $product->getCategory()
并使用该数据时,不会进行第二个查询。
现在,你可以在控制器中使用此方法来查询 Product
对象及其相关的 Category
,只需一个查询
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Controller/ProductController.php
// ...
class ProductController extends AbstractController
{
public function show(ProductRepository $productRepository, int $id): Response
{
$product = $productRepository->findOneByIdJoinedToCategory($id);
$category = $product->getCategory();
// ...
}
}
从反向侧设置信息
到目前为止,你已经通过调用 $product->setCategory($category)
更新了关系。这绝非偶然!每个关系都有两个方面:在此示例中,Product.category
是拥有侧,而 Category.products
是反向侧。
要在数据库中更新关系,你必须在拥有侧设置关系。拥有侧始终是设置 ManyToOne
映射的位置(对于 ManyToMany
关系,你可以选择哪一侧是拥有侧)。
这是否意味着无法调用 $category->addProduct()
或 $category->removeProduct()
来更新数据库?实际上,这是可能的,这要归功于 make:entity
命令生成的一些巧妙代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/Entity/Category.php
// ...
class Category
{
// ...
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setCategory($this);
}
return $this;
}
}
关键是 $product->setCategory($this)
,它设置了拥有侧。因此,当你保存时,关系将在数据库中更新。
那从 Category
中移除 Product
呢?make:entity
命令还生成了一个 removeProduct()
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// src/Entity/Category.php
namespace App\Entity;
// ...
class Category
{
// ...
public function removeProduct(Product $product): self
{
if ($this->products->contains($product)) {
$this->products->removeElement($product);
// set the owning side to null (unless already changed)
if ($product->getCategory() === $this) {
$product->setCategory(null);
}
}
return $this;
}
}
因此,如果你调用 $category->removeProduct($product)
,则该 Product
上的 category_id
将在数据库中设置为 null
。
警告
请注意,反向侧可能与大量记录关联。即,可能有很多产品具有相同的类别。在这种情况下,$this->products->contains($product)
可能会导致不必要的数据库请求和非常高的内存消耗,并有难以调试的“内存不足”错误的风险。
因此,请确保你是否需要反向侧,并检查生成的代码是否可能导致此类问题。
但是,与其将 category_id
设置为 null,如果希望 Product
在变为“孤立”(即没有 Category
)时被删除,该怎么办?要选择该行为,请使用 Category
内的 orphanRemoval 选项
1 2 3 4 5 6
// src/Entity/Category.php
// ...
#[ORM\OneToMany(targetEntity: Product::class, mappedBy: 'category', orphanRemoval: true)]
private array $products;
因此,如果从 Category
中删除了 Product
,它将从数据库中完全删除。