跳到内容

如何使用 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-ulidmake:entity。利用 Symfony 的 Uid 组件,这将生成一个实体,其 id 类型为 UuidUlid 而不是 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()很有用。如果你不想要它,那么你也不需要 inversedBymappedBy 配置。

__construct() 中的代码很重要:$products 属性必须是一个集合对象,该对象实现了 Doctrine 的 Collection 接口。在这种情况下,使用了 ArrayCollection 对象。这看起来和行为都非常像数组,但具有一些额外的灵活性。只需想象它是一个 array,你就会做得很好。

你的数据库已设置好!现在,像往常一样运行迁移

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 时,会在 categoryproduct 表中都添加一行。新产品的 product.category_id 列被设置为新类别的 id。Doctrine 为你管理此关系的持久化

如果你是 ORM 的新手,这是最难的概念:你需要停止考虑你的数据库,而考虑你的对象。你不是将类别的整数 id 设置到 Product 上,而是设置整个 Category 对象。Doctrine 在保存时会处理剩下的事情。

你也可以调用 $category->addProduct() 来更改关系吗?是的,但是,只是因为 make:entity 命令帮助了我们。有关更多详细信息,请参见:associations-inverse-side

当你需要获取关联对象时,你的工作流程看起来和以前一样。首先,获取一个 $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 来避免此额外的查询。

这种“延迟加载”是可能的,因为在必要时,Doctrine 返回一个“代理”对象来代替真实对象。再次查看上面的示例

1
2
3
4
5
6
7
$product = $productRepository->find($id);

$category = $product->getCategory();

// prints "Proxies\AppEntityCategoryProxy"
dump(get_class($category));
die();

此代理对象扩展了真实的 Category 对象,并且看起来和行为都完全相同。不同之处在于,通过使用代理对象,Doctrine 可以延迟查询真实的 Category 数据,直到你真正需要该数据时(例如,直到你调用 $category->getName() 时)。

代理类由 Doctrine 生成并存储在缓存目录中。你可能甚至永远不会注意到你的 $category 对象实际上是一个代理对象。

在下一节中,当你一次检索产品和类别数据(通过 join)时,Doctrine 将返回真实Category 对象,因为不需要延迟加载任何内容。

在上面的示例中,进行了两个查询 - 一个用于原始对象(例如 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,它将从数据库中完全删除。

关于关联的更多信息

本节是对一种常见的实体关系(一对多关系)的介绍。有关如何使用其他类型的关系(例如,一对一、多对多)的更多高级详细信息和示例,请参阅 Doctrine 的 关联映射文档

注意

如果你使用的是属性,则需要在所有属性前加上 #[ORM\](例如,#[ORM\OneToMany]),这在 Doctrine 的文档中没有反映出来。

这项工作,包括代码示例,均根据 Creative Commons BY-SA 3.0 许可获得许可。
目录
    版本