aggregate-fields.rst 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. Aggregate Fields
  2. ================
  3. .. sectionauthor:: Benjamin Eberlei <kontakt@beberlei.de>
  4. You will often come across the requirement to display aggregate
  5. values of data that can be computed by using the MIN, MAX, COUNT or
  6. SUM SQL functions. For any ORM this is a tricky issue
  7. traditionally. Doctrine 2 offers several ways to get access to
  8. these values and this article will describe all of them from
  9. different perspectives.
  10. You will see that aggregate fields can become very explicit
  11. features in your domain model and how this potentially complex
  12. business rules can be easily tested.
  13. An example model
  14. ----------------
  15. Say you want to model a bank account and all their entries. Entries
  16. into the account can either be of positive or negative money
  17. values. Each account has a credit limit and the account is never
  18. allowed to have a balance below that value.
  19. For simplicity we live in a world were money is composed of
  20. integers only. Also we omit the receiver/sender name, stated reason
  21. for transfer and the execution date. These all would have to be
  22. added on the ``Entry`` object.
  23. Our entities look like:
  24. .. code-block:: php
  25. <?php
  26. namespace Bank\Entities;
  27. /**
  28. * @Entity
  29. */
  30. class Account
  31. {
  32. /** @Id @GeneratedValue @Column(type="integer") */
  33. private $id;
  34. /** @Column(type="string", unique=true) */
  35. private $no;
  36. /**
  37. * @OneToMany(targetEntity="Entry", mappedBy="account", cascade={"persist"})
  38. */
  39. private $entries;
  40. /**
  41. * @Column(type="integer")
  42. */
  43. private $maxCredit = 0;
  44. public function __construct($no, $maxCredit = 0)
  45. {
  46. $this->no = $no;
  47. $this->maxCredit = $maxCredit;
  48. $this->entries = new \Doctrine\Common\Collections\ArrayCollection();
  49. }
  50. }
  51. /**
  52. * @Entity
  53. */
  54. class Entry
  55. {
  56. /** @Id @GeneratedValue @Column(type="integer") */
  57. private $id;
  58. /**
  59. * @ManyToOne(targetEntity="Account", inversedBy="entries")
  60. */
  61. private $account;
  62. /**
  63. * @Column(type="integer")
  64. */
  65. private $amount;
  66. public function __construct($account, $amount)
  67. {
  68. $this->account = $account;
  69. $this->amount = $amount;
  70. // more stuff here, from/to whom, stated reason, execution date and such
  71. }
  72. public function getAmount()
  73. {
  74. return $this->amount;
  75. }
  76. }
  77. Using DQL
  78. ---------
  79. The Doctrine Query Language allows you to select for aggregate
  80. values computed from fields of your Domain Model. You can select
  81. the current balance of your account by calling:
  82. .. code-block:: php
  83. <?php
  84. $dql = "SELECT SUM(e.amount) AS balance FROM Bank\Entities\Entry e " .
  85. "WHERE e.account = ?1";
  86. $balance = $em->createQuery($dql)
  87. ->setParameter(1, $myAccountId)
  88. ->getSingleScalarResult();
  89. The ``$em`` variable in this (and forthcoming) example holds the
  90. Doctrine ``EntityManager``. We create a query for the SUM of all
  91. amounts (negative amounts are withdraws) and retrieve them as a
  92. single scalar result, essentially return only the first column of
  93. the first row.
  94. This approach is simple and powerful, however it has a serious
  95. drawback. We have to execute a specific query for the balance
  96. whenever we need it.
  97. To implement a powerful domain model we would rather have access to
  98. the balance from our ``Account`` entity during all times (even if
  99. the Account was not persisted in the database before!).
  100. Also an additional requirement is the max credit per ``Account``
  101. rule.
  102. We cannot reliably enforce this rule in our ``Account`` entity with
  103. the DQL retrieval of the balance. There are many different ways to
  104. retrieve accounts. We cannot guarantee that we can execute the
  105. aggregation query for all these use-cases, let alone that a
  106. userland programmer checks this balance against newly added
  107. entries.
  108. Using your Domain Model
  109. -----------------------
  110. ``Account`` and all the ``Entry`` instances are connected through a
  111. collection, which means we can compute this value at runtime:
  112. .. code-block:: php
  113. <?php
  114. class Account
  115. {
  116. // .. previous code
  117. public function getBalance()
  118. {
  119. $balance = 0;
  120. foreach ($this->entries AS $entry) {
  121. $balance += $entry->getAmount();
  122. }
  123. return $balance;
  124. }
  125. }
  126. Now we can always call ``Account::getBalance()`` to access the
  127. current account balance.
  128. To enforce the max credit rule we have to implement the "Aggregate
  129. Root" pattern as described in Eric Evans book on Domain Driven
  130. Design. Described with one sentence, an aggregate root controls the
  131. instance creation, access and manipulation of its children.
  132. In our case we want to enforce that new entries can only added to
  133. the ``Account`` by using a designated method. The ``Account`` is
  134. the aggregate root of this relation. We can also enforce the
  135. correctness of the bi-directional ``Account`` <-> ``Entry``
  136. relation with this method:
  137. .. code-block:: php
  138. <?php
  139. class Account
  140. {
  141. public function addEntry($amount)
  142. {
  143. $this->assertAcceptEntryAllowed($amount);
  144. $e = new Entry($this, $amount);
  145. $this->entries[] = $e;
  146. return $e;
  147. }
  148. }
  149. Now look at the following test-code for our entities:
  150. .. code-block:: php
  151. <?php
  152. class AccountTest extends \PHPUnit_Framework_TestCase
  153. {
  154. public function testAddEntry()
  155. {
  156. $account = new Account("123456", $maxCredit = 200);
  157. $this->assertEquals(0, $account->getBalance());
  158. $account->addEntry(500);
  159. $this->assertEquals(500, $account->getBalance());
  160. $account->addEntry(-700);
  161. $this->assertEquals(-200, $account->getBalance());
  162. }
  163. public function testExceedMaxLimit()
  164. {
  165. $account = new Account("123456", $maxCredit = 200);
  166. $this->setExpectedException("Exception");
  167. $account->addEntry(-1000);
  168. }
  169. }
  170. To enforce our rule we can now implement the assertion in
  171. ``Account::addEntry``:
  172. .. code-block:: php
  173. <?php
  174. class Account
  175. {
  176. private function assertAcceptEntryAllowed($amount)
  177. {
  178. $futureBalance = $this->getBalance() + $amount;
  179. $allowedMinimalBalance = ($this->maxCredit * -1);
  180. if ($futureBalance < $allowedMinimalBalance) {
  181. throw new Exception("Credit Limit exceeded, entry is not allowed!");
  182. }
  183. }
  184. }
  185. We haven't talked to the entity manager for persistence of our
  186. account example before. You can call
  187. ``EntityManager::persist($account)`` and then
  188. ``EntityManager::flush()`` at any point to save the account to the
  189. database. All the nested ``Entry`` objects are automatically
  190. flushed to the database also.
  191. .. code-block:: php
  192. <?php
  193. $account = new Account("123456", 200);
  194. $account->addEntry(500);
  195. $account->addEntry(-200);
  196. $em->persist($account);
  197. $em->flush();
  198. The current implementation has a considerable drawback. To get the
  199. balance, we have to initialize the complete ``Account::$entries``
  200. collection, possibly a very large one. This can considerably hurt
  201. the performance of your application.
  202. Using an Aggregate Field
  203. ------------------------
  204. To overcome the previously mentioned issue (initializing the whole
  205. entries collection) we want to add an aggregate field called
  206. "balance" on the Account and adjust the code in
  207. ``Account::getBalance()`` and ``Account:addEntry()``:
  208. .. code-block:: php
  209. <?php
  210. class Account
  211. {
  212. /**
  213. * @Column(type="integer")
  214. */
  215. private $balance = 0;
  216. public function getBalance()
  217. {
  218. return $this->balance;
  219. }
  220. public function addEntry($amount)
  221. {
  222. $this->assertAcceptEntryAllowed($amount);
  223. $e = new Entry($this, $amount);
  224. $this->entries[] = $e;
  225. $this->balance += $amount;
  226. return $e;
  227. }
  228. }
  229. This is a very simple change, but all the tests still pass. Our
  230. account entities return the correct balance. Now calling the
  231. ``Account::getBalance()`` method will not occur the overhead of
  232. loading all entries anymore. Adding a new Entry to the
  233. ``Account::$entities`` will also not initialize the collection
  234. internally.
  235. Adding a new entry is therefore very performant and explicitly
  236. hooked into the domain model. It will only update the account with
  237. the current balance and insert the new entry into the database.
  238. Tackling Race Conditions with Aggregate Fields
  239. ----------------------------------------------
  240. Whenever you denormalize your database schema race-conditions can
  241. potentially lead to inconsistent state. See this example:
  242. .. code-block:: php
  243. <?php
  244. // The Account $accId has a balance of 0 and a max credit limit of 200:
  245. // request 1 account
  246. $account1 = $em->find('Bank\Entities\Account', $accId);
  247. // request 2 account
  248. $account2 = $em->find('Bank\Entities\Account', $accId);
  249. $account1->addEntry(-200);
  250. $account2->addEntry(-200);
  251. // now request 1 and 2 both flush the changes.
  252. The aggregate field ``Account::$balance`` is now -200, however the
  253. SUM over all entries amounts yields -400. A violation of our max
  254. credit rule.
  255. You can use both optimistic or pessimistic locking to save-guard
  256. your aggregate fields against this kind of race-conditions. Reading
  257. Eric Evans DDD carefully he mentions that the "Aggregate Root"
  258. (Account in our example) needs a locking mechanism.
  259. Optimistic locking is as easy as adding a version column:
  260. .. code-block:: php
  261. <?php
  262. class Amount
  263. {
  264. /** @Column(type="integer") @Version */
  265. private $version;
  266. }
  267. The previous example would then throw an exception in the face of
  268. whatever request saves the entity last (and would create the
  269. inconsistent state).
  270. Pessimistic locking requires an additional flag set on the
  271. ``EntityManager::find()`` call, enabling write locking directly in
  272. the database using a FOR UPDATE.
  273. .. code-block:: php
  274. <?php
  275. use Doctrine\DBAL\LockMode;
  276. $account = $em->find('Bank\Entities\Account', $accId, LockMode::PESSIMISTIC_READ);
  277. Keeping Updates and Deletes in Sync
  278. -----------------------------------
  279. The example shown in this article does not allow changes to the
  280. value in ``Entry``, which considerably simplifies the effort to
  281. keep ``Account::$balance`` in sync. If your use-case allows fields
  282. to be updated or related entities to be removed you have to
  283. encapsulate this logic in your "Aggregate Root" entity and adjust
  284. the aggregate field accordingly.
  285. Conclusion
  286. ----------
  287. This article described how to obtain aggregate values using DQL or
  288. your domain model. It showed how you can easily add an aggregate
  289. field that offers serious performance benefits over iterating all
  290. the related objects that make up an aggregate value. Finally I
  291. showed how you can ensure that your aggregate fields do not get out
  292. of sync due to race-conditions and concurrent access.