Patterns Behind Doctrine

Patterns Behind Doctrine

How does Doctrine talk to your database? What are Data Mapper, Unit Of Work, and Identity Map? These are the questions I want to answer in this talk. We will look at how Doctrine ORM implements them and what they are there for. Finally we will look at how they compare to Active Record and what the benefits and drawbacks are to help you choose which one fits your needs best.

6a1345d8e6dd15b2c78eff0c331963b1?s=128

Denis Brumann

December 06, 2018
Tweet

Transcript

  1. THE PATTERNS BEHIND DOCTRINE Denis Brumann

  2. Denis Brumann denis.brumann@sensiolabs.de
 @dbrumann Software Developer Berlin, Germany

  3. None
  4. None
  5. <?php declare(strict_types = 1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM;

    /** * @ORM\Entity(repositoryClass="App\Repository\OrderItemRepository") * @ORM\Table(name="app_order_item") */ class OrderItem { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\ManyToOne(targetEntity="App\Entity\Order", inversedBy="items") * @ORM\JoinColumn(nullable=false)
  6. <?php declare(strict_types = 1); namespace App\Entity; use Doctrine\ORM\Mapping as ORM;

    /** * @ORM\Entity(repositoryClass="App\Repository\OrderItemRepository") * @ORM\Table(name="app_order_item") */ class OrderItem { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\ManyToOne(targetEntity="App\Entity\Order", inversedBy="items") * @ORM\JoinColumn(nullable=false)
  7. /** * @ORM\ManyToOne(targetEntity="App\Entity\Order", inversedBy="items") * @ORM\JoinColumn(nullable=false) */ private $order; /**

    * @ORM\Column() */ private $name; /** * @ORM\Column(length=16) */ private $price; public function getId(): int { return $this->id ?? 0; } public function getOrder(): Order
  8. <?php declare(strict_types = 1); namespace App\Entity; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection;

    use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass="App\Repository\OrderRepository") * @ORM\Table(name="app_order") */ class Order { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\OneToMany(targetEntity="App\Entity\OrderItem", mappedBy="order", cascade={"persist"}) */ private $items; /**
  9. <?php declare(strict_types = 1); namespace App\Entity; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection;

    use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass="App\Repository\OrderRepository") * @ORM\Table(name="app_order") */ class Order { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\OneToMany(targetEntity="App\Entity\OrderItem", mappedBy="order", cascade={"persist"}) */ private $items; /**
  10. <?php declare(strict_types = 1); namespace App\Entity; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection;

    use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass="App\Repository\OrderRepository") * @ORM\Table(name="app_order") */ class Order { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\OneToMany(targetEntity="App\Entity\OrderItem", mappedBy="order", cascade={"persist"}) */ private $items; /**
  11. /** * @ORM\Column(type="datetime_immutable") */ private $createdOn; public function __construct() {

    $this->items = new ArrayCollection(); $this->createdOn = new DateTimeImmutable(); } public function getId(): int { return $this->id ?? 0; } public function getCreatedOn(): DateTimeImmutable { return $this->createdOn; } public function getItems(): array { return $this->items->toArray(); } public function addItem(OrderItem $item): void {
  12. /** * @ORM\Column(type="datetime_immutable") */ private $createdOn; public function __construct() {

    $this->items = new ArrayCollection(); $this->createdOn = new DateTimeImmutable(); } public function getId(): int { return $this->id ?? 0; } public function getCreatedOn(): DateTimeImmutable { return $this->createdOn; } public function getItems(): array { return $this->items->toArray(); } public function addItem(OrderItem $item): void {
  13. } public function addItem(OrderItem $item): void { $this->items->add($item); $item->setOrder($this); }

    public function removeItem(OrderItem $item): void { $this->items->remove($item); } public function getItemCount(): int { return $this->items->count(); } public function getTotal(): string { $total = '0'; foreach ($this->items as $item) { $total = bcadd($total, $item->getPrice()); } return $total; } }
  14. } public function addItem(OrderItem $item): void { $this->items->add($item); $item->setOrder($this); }

    public function removeItem(OrderItem $item): void { $this->items->remove($item); } public function getItemCount(): int { return $this->items->count(); } public function getTotal(): string { $total = '0'; foreach ($this->items as $item) { $total = bcadd($total, $item->getPrice()); } return $total; } }
  15. INSERT

  16. <?php declare(strict_types = 1); namespace App\Order; use App\Entity\Order; use App\Entity\OrderItem;

    use Doctrine\ORM\EntityManagerInterface; class Checkout { private $manager; public function __construct(EntityManagerInterface $manager) { $this->manager = $manager; } public function checkout(array $cartItems): void { $order = new Order(); foreach ($cartItems as $cartItem) { $orderItem = new OrderItem();
  17. public function checkout(array $cartItems): void { $order = new Order();

    foreach ($cartItems as $cartItem) { $orderItem = new OrderItem(); $orderItem->setName($cartItem->getName()); $orderItem->setPrice($cartItem->getPrice()); $order->addItem($orderItem); } $this->manager->persist($order); $this->manager->flush(); }
  18. HOW DOES DOCTRINE KNOW WHAT TO SAVE? We only called

    persist on our order,
 not on the items.
  19. Maintains a list of objects affected by a business transaction

    and coordinates the writing out of changes and the resolution of concurrency problems. UNIT OF WORK
  20. None
  21. None
  22. None
  23. None
  24. <?php declare(strict_types = 1); namespace App\Entity; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection;

    use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass="App\Repository\OrderRepository") * @ORM\Table(name="app_order") */ class Order { /** * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\OneToMany(targetEntity="App\Entity\OrderItem", mappedBy="order", cascade={"persist"}) */ private $items; /**
  25. None
  26. None
  27. public function checkout(array $cartItems): void { $order = new Order();

    foreach ($cartItems as $cartItem) { $orderItem = new OrderItem(); $orderItem->setName($cartItem->getName()); $orderItem->setPrice($cartItem->getPrice()); $order->addItem($orderItem); $this->manager->persist($orderItem); } $this->manager->persist($order); $this->manager->flush(); }
  28. None
  29. Doctrine ORM recognizes unintentionally non-persisted entities. You have to add

    cascade to the association or persist each entity manually. Only entities managed by the UnitOfWork will be inserted.
  30. None
  31. $item = new OrderItem(); $item->setName('Socks'); $item->setPrice('999'); $manager->persist($item); $order = new

    Order(); $order->addItem($item); $manager->persist($order); $manager->flush();
  32. $item = new OrderItem(); $item->setName('Socks'); $item->setPrice('999'); $manager->persist($item); $order = new

    Order(); $order->addItem($item); $manager->persist($order); $manager->flush();
  33. $item = new OrderItem(); $item->setName('Socks'); $item->setPrice('999'); $manager->persist($item); $order = new

    Order(); $order->addItem($item); $manager->persist($order); $manager->flush();
  34. UnitOfWork will take care of correctly ordering each commit. You

    can persist your entities in any order you like.
  35. FIND

  36. None
  37. {% extends "base.html.twig" %} {% block body %} <h1>Order list</h1>

    <div class="btn-group mb-2" role="group"> <a href="{{ path('create') }}" class="btn btn-primary">Create sample orders</a> <a href="{{ path('truncate') }}" class="btn btn-primary">Truncate orders</a> </div> <div class="list-group mb-4"> {% for order in orders %} <a href="{{ path('show', {'id': order.id}) }}" class="list-group-item list-group-item-action flex-column align-items-start"> <div class="d-flex w-100 justify-content-between"> <h5 class="mb-1">Order number #{{ order.id }}</h5> <small>{{ order.createdOn|date('d.m.Y') }} </small> </div> <p class="mb-1">contains {{ order.items|length }} items for a total of <strong>{{ order.total / 100 }}€</strong>.</p> </a> {% endfor %} </div> {% endblock %}
  38. {% extends "base.html.twig" %} {% block body %} <h1>Order list</h1>

    <div class="btn-group mb-2" role="group"> <a href="{{ path('create') }}" class="btn btn-primary">Create sample orders</a> <a href="{{ path('truncate') }}" class="btn btn-primary">Truncate orders</a> </div> <div class="list-group mb-4"> {% for order in orders %} <a href="{{ path('show', {'id': order.id}) }}" class="list-group-item list-group-item-action flex-column align-items-start"> <div class="d-flex w-100 justify-content-between"> <h5 class="mb-1">Order number #{{ order.id }}</h5> <small>{{ order.createdOn|date('d.m.Y') }} </small> </div> <p class="mb-1">contains {{ order.items|length }} items for a total of <strong>{{ order.total / 100 }}€</strong>.</p> </a> {% endfor %} </div> {% endblock %}
  39. None
  40. None
  41. LAZY LOADING

  42. None
  43. None
  44. None
  45. None
  46. None
  47. <?php namespace Proxies\__CG__\App\Entity; /** * DO NOT EDIT THIS FILE

    - IT WAS CREATED BY DOCTRINE'S PROXY GENERATOR */ class Order extends \App\Entity\Order implements \Doctrine\ORM\Proxy\Proxy { /** * @var \Closure the callback responsible for loading properties in the proxy object. This callback is called with * three parameters, being respectively the proxy object to be initialized, the method that triggered the * initialization process and an array of ordered parameters that were passed to that method. * * @see \Doctrine\Common\Persistence\Proxy::__setInitializer */ public $__initializer__; /**
  48. 'getCreatedOn', []); return parent::getCreatedOn(); } /** * {@inheritDoc} */ public

    function getItems(): array { $this->__initializer__ && $this->__initializer__->__invoke($this, 'getItems', []); return parent::getItems(); } /** * {@inheritDoc} */ public function addItem(\App\Entity\OrderItem $item): void {
  49. None
  50. FETCH MODES

  51. /** * @ORM\Entity(repositoryClass="App\Repository\OrderRepository") * @ORM\Table(name="app_order") */ class Order { /**

    * @ORM\Id() * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\OneToMany( * targetEntity="App\Entity\OrderItem", * mappedBy="order", * cascade={"persist"}, * fetch="EAGER" * ) */ private $items;
  52. public function findOrderWithItems(int $id): Order { return $this->createQueryBuilder('o') ->select(['o', 'i'])

    ->where('o.id = :order_id') ->setParameter('order_id', $id) ->join('o.items', 'i') ->getQuery() ->getSingleResult(); }
  53. public function findOrderWithItems(int $id): Order { return $this->createQueryBuilder('o') ->select(['o', 'i'])

    ->where('o.id = :order_id') ->setParameter('order_id', $id) ->join('o.items', 'i') ->getQuery() ->getSingleResult(); }
  54. public function findOrderWithItems(int $id): Order { return $this->createQueryBuilder('o') ->select(['o', 'i'])

    ->where('o.id = :order_id') ->setParameter('order_id', $id) ->join('o.items', 'i') ->getQuery() ->getSingleResult(); }
  55. CUSTOM OBJECT HYDRATION

  56. <?php declare(strict_types = 1); namespace App\DTO; use DateTimeImmutable; final class

    OrderSummary { private $orderId; private $orderDate; private $itemCount; private $total; public function __construct(int $orderId, DateTimeImmutable $orderDate, int $itemCount, int $total) { $this->orderId = $orderId; $this->orderDate = $orderDate; $this->itemCount = $itemCount; $this->total = $total; } public function getOrderId(): int { return $this->orderId; } public function getOrderDate(): DateTimeImmutable { return $this->orderDate; }
  57. { $this->orderId = $orderId; $this->orderDate = $orderDate; $this->itemCount = $itemCount;

    $this->total = $total; } public function getOrderId(): int { return $this->orderId; } public function getOrderDate(): DateTimeImmutable { return $this->orderDate; } public function getItemCount(): int { return $this->itemCount; } public function getTotal(): int { return $this->total; } }
  58. public function findSummarizedOrders(): array { $dql = 'SELECT NEW App\\DTO\\OrderSummary(

    ' . 'o.id, o.createdOn, COUNT(i.order), SUM(i.price)' . ') FROM App\\Entity\\Order o ' . 'JOIN o.items i GROUP BY i.order'; return $this->getEntityManager() ->createQuery($dql) ->getResult(); }
  59. SELECT o.id, o.created_on, COUNT(i.order_id), SUM(i.price) FROM app_order AS o JOIN

    app_order_item i on o.id = i.order_id GROUP BY o.id
  60. None
  61. None
  62. public function findMostExpensiveOrder(): ?Order { $sql = <<<SQL SELECT o.*,

    SUM(i.price) AS order_sum FROM app_order AS o JOIN app_order_item i ON o.id = i.order_id GROUP BY i.order_id ORDER BY order_sum DESC LIMIT 1; SQL; $resultSetMapping = new ResultSetMappingBuilder($this->getEntityManager()); $resultSetMapping->addRootEntityFromClassMetadata(Order::class, 'o'); return $this->getEntityManager()->createNativeQuery($sql, $resultSetMapping)->getOneOrNullResult(); }
  63. Doctrine loads associated entities lazily by default. Writing custom queries

    with JOIN, hydrate results into custom DTO or as scalar results can improve read-performance.
  64. IDENTITY MAP

  65. None
  66. $order = $this->em->find(Order::class, 1); dump($this->em->getUnitOfWork()->getIdentityMap()); $sameOrder = $this->em->find(Order::class, 1); dump($order

    === $sameOrder);
  67. $order = $this->em->find(Order::class, 1); dump($this->em->getUnitOfWork()->getIdentityMap()); $sameOrder = $this->em->find(Order::class, 1); dump($order

    === $sameOrder);
  68. $order = $this->em->find(Order::class, 1); dump($this->em->getUnitOfWork()->getIdentityMap()); $sameOrder = $this->em->find(Order::class, 1); dump($order

    === $sameOrder);
  69. None
  70. None
  71. None
  72. None
  73. Doctrine stores previously loaded entities in an IdentityMap. Loading an

    entity multiple times does only perform 1 database query (in some special cases). Each new find() returns the current entity (with its unsaved changes).
  74. UPDATE

  75. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = new Order();

    $order->setId(1); $order->addItem($item); $this->manager->flush();
  76. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = new Order();

    $order->setId(1); $order->addItem($item); $this->manager->flush(); WILL THIS WORK?
  77. None
  78. None
  79. Entity States A NEW entity instance has no persistent identity,

    and is not yet associated with an EntityManager and a UnitOfWork (i.e. those just created with the "new" operator).
  80. Entity States A MANAGED entity instance is an instance with

    a persistent identity that is associated with an EntityManager and whose persistence is thus managed.
  81. Entity States A DETACHED entity instance is an instance with

    a persistent identity that is not (or no longer) associated with an EntityManager and a UnitOfWork.
  82. Entity States A REMOVED entity instance is an instance with

    a persistent identity, associated with an EntityManager, that will be removed from the database upon transaction commit.
  83. /** * An entity is in MANAGED state when its

    persistence is managed by an EntityManager. */ const STATE_MANAGED = 1; /** * An entity is new if it has just been instantiated (i.e. using the "new" operator) * and is not (yet) managed by an EntityManager. */ const STATE_NEW = 2; /** * A detached entity is an instance with persistent state and identity that is not * (or no longer) associated with an EntityManager (and a UnitOfWork). */ const STATE_DETACHED = 3; /** * A removed entity instance is an instance with a persistent identity, * associated with an EntityManager, whose persistent state will be deleted * on commit. */ const STATE_REMOVED = 4;
  84. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = new Order();

    $order->setId(1); $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  85. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = $this->entityManager
 ->find(Order::class,

    $id); $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  86. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = $this->entityManager
 ->find(Order::class,

    $id); $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  87. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = $this->entityManager
 ->find(Order::class,

    $id); $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  88. $repository = $this->entityManager->getRepository(Order::class); $order = $repository->findOrderWithItems($id); $items = $order->getItems(); $randomItem

    = $items[random_int(0, count($items) - 1)]; $randomItem->setPrice('1'); $this->entityManager->flush();
  89. None
  90. None
  91. None
  92. { "00000000072a344b000000003da8d2f1": { "price": ["119","1"] } }

  93. $oid = spl_object_hash($entity)

  94. $oid = spl_object_hash($entity) This function returns a unique identifier for

    the object. This id can be used as a hash key for storing objects, or for identifying an object, as long as the object is not destroyed. Once the object is destroyed, its hash may be reused for other objects.
  95. $oid = "00000000600cd8330000000053755686"

  96. Doctrine internally references objects through their object hash, instead of

    their id. Simply adding an id to an entity will perform an insert, not an update.
  97. ACTIVE RECORD

  98. An object that wraps a row in a database table

    or view, encapsulates the database access, and adds domain logic on that data. https://www.martinfowler.com/eaaCatalog/activeRecord.html
  99. Models allow you to query for data in your tables,

    as well as insert new records into the table.
  100. /** * Doctrine_Record * All record classes should inherit this

    super class * * @package Doctrine * @subpackage Record * @author Konsta Vesterinen <kvesteri@cc.hut.fi> * @license http://www.opensource.org/licenses/lgpl-license.php LGPL * @link www.doctrine-project.org * @since 1.0 * @version $Revision: 7673 $ */ abstract class Doctrine_Record extends Doctrine_Record_Abstract implements Countable, IteratorAggregate, Serializable { /** * STATE CONSTANTS */ /** * DIRTY STATE * a Doctrine_Record is in dirty state when its properties are changed */ const STATE_DIRTY = 1; /** * TDIRTY STATE * a Doctrine_Record is in transient dirty state when it is created * and some of its fields are modified but it is NOT yet persisted into database
  101. * TDIRTY STATE * a Doctrine_Record is in transient dirty

    state when it is created * and some of its fields are modified but it is NOT yet persisted into database */ const STATE_TDIRTY = 2; /** * CLEAN STATE * a Doctrine_Record is in clean state when all of its properties are loaded from the database * and none of its properties are changed */ const STATE_CLEAN = 3; /** * PROXY STATE * a Doctrine_Record is in proxy state when its properties are not fully loaded */ const STATE_PROXY = 4; /** * NEW TCLEAN * a Doctrine_Record is in transient clean state when it is created and none of its fields are modified */ const STATE_TCLEAN = 5; /** * LOCKED STATE * a Doctrine_Record is temporarily locked during deletes and saves *
  102. * TDIRTY STATE * a Doctrine_Record is in transient dirty

    state when it is created * and some of its fields are modified but it is NOT yet persisted into database */ const STATE_TDIRTY = 2; /** * CLEAN STATE * a Doctrine_Record is in clean state when all of its properties are loaded from the database * and none of its properties are changed */ const STATE_CLEAN = 3; /** * PROXY STATE * a Doctrine_Record is in proxy state when its properties are not fully loaded */ const STATE_PROXY = 4; /** * NEW TCLEAN * a Doctrine_Record is in transient clean state when it is created and none of its fields are modified */ const STATE_TCLEAN = 5; /** * LOCKED STATE * a Doctrine_Record is temporarily locked during deletes and saves *
  103. * TDIRTY STATE * a Doctrine_Record is in transient dirty

    state when it is created * and some of its fields are modified but it is NOT yet persisted into database */ const STATE_TDIRTY = 2; /** * CLEAN STATE * a Doctrine_Record is in clean state when all of its properties are loaded from the database * and none of its properties are changed */ const STATE_CLEAN = 3; /** * PROXY STATE * a Doctrine_Record is in proxy state when its properties are not fully loaded */ const STATE_PROXY = 4; /** * NEW TCLEAN * a Doctrine_Record is in transient clean state when it is created and none of its fields are modified */ const STATE_TCLEAN = 5; /** * LOCKED STATE * a Doctrine_Record is temporarily locked during deletes and saves *
  104. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149');
 $order = new Order();

    $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  105. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149');
 $order = new Order();

    $order->addItem($item); $this->manager->persist($order); $this->manager->flush();
  106. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149');
 $order = new Order();

    $order->addItem($item); $order->save();
  107. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = $this->entityManager
 ->find(Order::class,

    $id); $order->addItem($item); $this->manager->flush();
  108. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = $this->entityManager
 ->find(Order::class,

    $id); $order->addItem($item); $this->manager->flush();
  109. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = Order::find($id); $order->addItem($item);

    $this->manager->flush();
  110. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = Order::find($id); $order->addItem($item);

    $this->manager->flush();
  111. $item = new OrderItem(); $item->setName('Coffee'); $item->setPrice('149'); $order = Order::find($id); $order->addItem($item);

    $order->save();
  112. Data Mapper Active Record logic & data is split up

    into multiple classes:
 Entity Manager, UnitOfWork, Repository carries both data and persistence logic:
 Base Model explicit configuration implicit configuration
 (convention over configuration) more code to write less code to write Model can be independent from ORM places restrictions on model
 no private properties, inherit __construct
  113. UnitOfWork controls entity states, commit order, what to write and

    how to write (insert/update). Summary
  114. Proxy allows to lazily load associated entities, only when they

    are needed. Summary
  115. IdentityMap prevents EntityManager from performing the same SELECT-query multiple times.

    Summary