oasis / doctrine-addon
Doctrine add-ons
Installs: 9 578
Dependents: 1
Suggesters: 0
Security: 0
Stars: 0
Watchers: 5
Forks: 0
Open Issues: 0
Requires
- doctrine/orm: ^2.5
- oasis/logging: ^1.1
Requires (Dev)
- phpunit/phpunit: ^5.3
This package is auto-updated.
Last update: 2024-12-04 19:16:33 UTC
README
Doctrine is the most popular vendor for PHP database components. The core projects under this name are a Object Relational Mapper (ORM) and the Database Abstraction Layer (DBAL) it is built upon.
The oasis/doctrine-addon component provides a few useful features to extend the doctrine/orm component:
- a trait to ease declaration of auto generated id field
- a trait/mechanism to make cache invalidating more flexible, during object removal phase
Installation
Install the latest version with command below:
$ composer require oasis/doctrine-addon
AutoIdTrait
The AutoIdTrait
defines an id
property and the getter method getId()
. It can be used in any entity class that uses id
as its primary index.
Cascade Removal Solution
When cache is presented for an ORM-enabled application, cache invalidation has always been an interesting topic to deal with. One most common exception in ORM is when you try to access a collection that contains out-dated entity. This problem is commonly referred to as the cascade removal problem. An exmaple is like below:
You have a Tean entity that holds a collection of its members, which are User entities. When you remove a User entity, without proper invalidation process, accessing the Team that holds reference to this User will throw an exception.
By default, doctrine/orm provides two ways to solve this problem:
- Manual invalidation upon each removal
- Use the
cascade={"remove"}
annotation
However, either of the two solutions are not optimal. Needless to say, the manual removal is complex and will become a nightmare when you have a reference chain. On the other hand, using the cascade
annotation is very effecient development wise, but extremely slow in performance especially when the relation map is complicated and dataset is large.
oasis/doctrine-addon introduces another solution to the cascade removal problem, by providing the CascadeRemoveTrait
. Any entity that wants to utilise the CascadeRemoveTrait
must:
- declare with the
ORM\HasLifecycleCallbacks
annotation - implement the
CascadeRemovableInterface
- use the
CascadeRemoveTrait
- use database provided schema to delete strongly related entities
Implementing the CascadeRemovableInterface
further requires implementation of the following two methods:
getCascadeRemoveableEntities()
, which takes no arguments and returns an array of strongly related entitiesgetDirtyEntitiesOnInvalidation()
, which takes no arguments and returns an array of loosely related entities
A strongly related entity is an entity that should also be removed when the current entity is removed.
A loosely related entity is an entity that holds a reference to the current entity, either directly (To-One relation) or through a collection (To-Many relation). This reference should be invalidated when the current entity is removed.
The real removal of strongly related entity is achieved by database constraint, which must be ON DELETE CASCADE
.
Code Sample (very simple CMS)
Imagine we have a simple CMS that has 3 types of entities: Categroy, Article and Tag. The system must meet the following requirements:
- An Article may belong to a Category
- An Article and can have more than one Tag
- Different Articles can share Tags
Below is the implementation:
<?php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Oasis\Mlib\Doctrine\AutoIdTrait; use Oasis\Mlib\Doctrine\CascadeRemovableInterface; use Oasis\Mlib\Doctrine\CascadeRemoveTrait; /** * Class Article * * @ORM\Entity() * @ORM\Table(name="articles") * * @ORM\HasLifecycleCallbacks() */ class Article implements CascadeRemovableInterface { use CascadeRemoveTrait; use AutoIdTrait; /** * @var Category * @ORM\ManyToOne(targetEntity="Category", inversedBy="articles") * @ORM\JoinColumn(onDelete="CASCADE") */ protected $category; /** * @var ArrayCollection * @ORM\ManyToMany(targetEntity="Tag", inversedBy="articles") * @ORM\JoinTable(name="article_tags", * inverseJoinColumns={@ORM\JoinColumn(name="tag", referencedColumnName="id", onDelete="CASCADE")}, * joinColumns={@ORM\JoinColumn(name="`article`", referencedColumnName="id", onDelete="CASCADE")}) */ protected $tags; public function __construct() { $this->tags = new ArrayCollection(); } function __toString() { return sprintf("Article #%s", $this->getId()); } public function addTag(Tag $tag) { if (!$this->tags->contains($tag)) { $this->tags->add($tag); /** @noinspection PhpInternalEntityUsedInspection */ $tag->addArticle($this); } } /** * @return array an array of entities which will also be removed when the calling entity is remvoed */ public function getCascadeRemoveableEntities() { return []; } /** * @return array an array of entities asscociated to the calling entity, which should be detached when calling * entity is removed. */ public function getDirtyEntitiesOnInvalidation() { return $this->tags->toArray(); } /** * @return Category */ public function getCategory() { return $this->category; } /** * @param Category $category */ public function setCategory($category) { if ($this->category == $category) { return; } if ($this->category) { /** @noinspection PhpInternalEntityUsedInspection */ $this->category->removeArticle($this); } $this->category = $category; if ($category) { /** @noinspection PhpInternalEntityUsedInspection */ $category->addArticle($this); } } /** * @return ArrayCollection */ public function getTags() { return $this->tags; } }
<?php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Oasis\Mlib\Doctrine\AutoIdTrait; use Oasis\Mlib\Doctrine\CascadeRemovableInterface; use Oasis\Mlib\Doctrine\CascadeRemoveTrait; /** * Class Category * * @ORM\Entity() * @ORM\Table(name="categories") * * @ORM\HasLifecycleCallbacks() */ class Category implements CascadeRemovableInterface { use CascadeRemoveTrait; use AutoIdTrait; /** * @var ArrayCollection * @ORM\OneToMany(targetEntity="Article", mappedBy="category") */ protected $articles; /** * @var Category * @ORM\ManyToOne(targetEntity="Category", inversedBy="children") * @ORM\JoinColumn(onDelete="SET NULL"); */ protected $parent; /** * @var ArrayCollection * @ORM\OneToMany(targetEntity="Category", mappedBy="parent") */ protected $children; public function __construct() { $this->articles = new ArrayCollection(); $this->children = new ArrayCollection(); } function __toString() { return '111'; } /** * @param Article $article * * @internal */ public function addArticle($article) { if (!$this->articles->contains($article)) { $this->articles->add($article); } } /** * @param $child * * @internal */ public function addChild($child) { if (!$this->children->contains($child)) { $this->children->add($child); } } /** * @param Article $article * * @internal */ public function removeArticle($article) { if ($this->articles->contains($article)) { $this->articles->remove($article); } } /** * @param $child * * @internal */ public function removeChild($child) { if ($this->children->contains($child)) { $this->children->remove($child); } } /** * @return array an array of entities which will also be removed when the calling entity is remvoed */ public function getCascadeRemoveableEntities() { return $this->articles->toArray(); } /** * @return array an array of entities asscociated to the calling entity, which should be detached when calling * entity is removed. */ public function getDirtyEntitiesOnInvalidation() { return []; } /** * @return ArrayCollection */ public function getChildren() { return $this->children; } /** * @return Category */ public function getParent() { return $this->parent; } /** * @param Category $parent */ public function setParent($parent) { if ($this->parent) { $this->parent->removeChild($this); } $this->parent = $parent; if ($parent) { $parent->addChild($this); } } }
<?php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Oasis\Mlib\Doctrine\AutoIdTrait; use Oasis\Mlib\Doctrine\CascadeRemovableInterface; use Oasis\Mlib\Doctrine\CascadeRemoveTrait; /** * Class Tag * * @ORM\Entity() * * @ORM\HasLifecycleCallbacks() */ class Tag implements CascadeRemovableInterface { use CascadeRemoveTrait; use AutoIdTrait; /** * @var ArrayCollection * * @ORM\ManyToMany(targetEntity="Article", mappedBy="tags") */ protected $articles; public function __construct() { $this->articles = new ArrayCollection(); } function __toString() { return sprintf("Tag #%s", $this->getId()); } /** * @param Article $article * @internal */ public function addArticle(Article $article) { if (!$this->articles->contains($article)) { $this->articles->add($article); } } /** * @return ArrayCollection */ public function getArticles() { return $this->articles; } /** * @return array an array of entities which will also be removed when the calling entity is remvoed */ public function getCascadeRemoveableEntities() { return []; } /** * @return array an array of entities asscociated to the calling entity, which should be detached when calling * entity is removed. */ public function getDirtyEntitiesOnInvalidation() { return $this->articles->toArray(); } }
NOTE: there is always one side and only one side in a bidirectional relation, that should be accessed by the outside users. In this example, you
setCategory()
on an Article, and you never calladdArticle()
directly on a Category. To further ensure this behavior and let the IDE helps us locate potential bug, we should decalre the hidden method@internal
so that every call from outside will trigger a warning.
HOMEWORK: there is only
addTag()
method on an Article. You can try to write your implementation ofremoveTag()
method to futher familiarize yourself with the ORM tool.