Added Aggregate Fields documentation
This commit is contained in:
parent
8d2da96480
commit
59aafe100b
@ -7,4 +7,5 @@
|
||||
+ DQL Custom Walkers
|
||||
+ DQL User Defined Functions
|
||||
+ SQL Table Prefixes
|
||||
+ Strategy Cookbook Introduction
|
||||
+ Strategy Cookbook Introduction
|
||||
+ Aggregate Fields
|
320
cookbook/en/aggregate-fields.txt
Normal file
320
cookbook/en/aggregate-fields.txt
Normal file
@ -0,0 +1,320 @@
|
||||
# Aggregate Fields
|
||||
|
||||
You will is often the requirement to display aggregate values of data that
|
||||
can be computed by using the MIN, MAX, COUNT or SUM SQL functions. Doctrine 2
|
||||
offers several ways to get access to these values and this article will
|
||||
describe all of them from different perspectives.
|
||||
|
||||
You will see that aggregate fields can become very explicit
|
||||
features in your domain model and how this potentially complex business rules
|
||||
can be easily tested.
|
||||
|
||||
## An example model
|
||||
|
||||
Say you want to model a bank account and all their entries. Entries
|
||||
into the account can either be of positive or negative money values.
|
||||
Each account has a credit limit and the account is never allowed
|
||||
to have a balance below that value.
|
||||
|
||||
For simplicity we live in a world were money is composed of integers
|
||||
only. Also we omit the receiver/sender name, stated reason for transfer
|
||||
and the execution date. These all would have to be added on the `Entry`
|
||||
object.
|
||||
|
||||
Our entities look like:
|
||||
|
||||
[php]
|
||||
namespace Bank\Entities;
|
||||
|
||||
/**
|
||||
* @Entity
|
||||
*/
|
||||
class Account
|
||||
{
|
||||
/** @Id @GeneratedValue @Column(type="integer") */
|
||||
private $id;
|
||||
|
||||
/** @Column(type="string", unique=true)
|
||||
private $no;
|
||||
|
||||
/**
|
||||
* @OneToMany(targetEntity="Entry", mappedBy="entries", cascade={"persist"})
|
||||
*/
|
||||
private $entries;
|
||||
|
||||
/**
|
||||
* @Column(type="integer")
|
||||
*/
|
||||
private $maxCredit = 0;
|
||||
|
||||
public function __construct($no, $maxCredit = 0)
|
||||
{
|
||||
$this->no = $no;
|
||||
$this->maxCredit = $maxCredit;
|
||||
$this->entries = new \Doctrine\Common\Collections\ArrayCollection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Entity
|
||||
*/
|
||||
class Entry
|
||||
{
|
||||
/** @Id @GeneratedValue @Column(type="integer") */
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @ManyToOne(targetEntity="Account", inversedBy="entries")
|
||||
*/
|
||||
private $account;
|
||||
|
||||
/**
|
||||
* @Column(type="integer")
|
||||
*/
|
||||
private $amount;
|
||||
|
||||
public function __construct($account, $amount)
|
||||
{
|
||||
$this->account = $account;
|
||||
$this->amount = $amount;
|
||||
// more stuff here, from/to whom, stated reason, execution date and such
|
||||
}
|
||||
|
||||
public function getAmount()
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
}
|
||||
|
||||
## Using DQL
|
||||
|
||||
The Doctrine Query Language allows you to select for aggregate values computed from
|
||||
fields of your Domain Model. You can select the current balance of your account by
|
||||
calling:
|
||||
|
||||
[php]
|
||||
$dql = "SELECT SUM(e.amount) AS balance FROM Bank\Entities\Entry e " .
|
||||
"WHERE e.account = ?1";
|
||||
$balance = $em->createQuery($dql)
|
||||
->setParameter(1, $myAccountId)
|
||||
->getSingleScalarResult();
|
||||
|
||||
The `$em` variable in this (and forthcoming) example holds the Doctrine `EntityManager`.
|
||||
We create a query for the SUM of all amounts (negative amounts are withdraws) and
|
||||
retrieve them as a single scalar result, essentially return only the first column
|
||||
of the first row.
|
||||
|
||||
This approach is simple and powerful, however it has a serious drawback. We have
|
||||
to execute a specific query for the balance whenever we need it.
|
||||
|
||||
To implement a powerful domain model we would rather have access to the balance from
|
||||
our `Account` entity during all times (even if the Account was not persisted
|
||||
in the database before!).
|
||||
|
||||
Also an additional requirement is the max credit per `Account` rule.
|
||||
|
||||
We cannot reliably enforce this rule in our `Account` entity with the DQL retrieval
|
||||
of the balance. There are many different ways to retrieve accounts. We cannot
|
||||
guarantee that we can execute the aggregation query for all these use-cases,
|
||||
let alone that a userland programmer checks this balance against newly added
|
||||
entries.
|
||||
|
||||
## Using your Domain Model
|
||||
|
||||
`Account` and all the `Entry` instances are connected through a collection,
|
||||
which means we can compute this value at runtime:
|
||||
|
||||
[php]
|
||||
class Account
|
||||
{
|
||||
// .. previous code
|
||||
public function getBalance()
|
||||
{
|
||||
$balance = 0;
|
||||
foreach ($this->entries AS $entry) {
|
||||
$balance += $entry->getAmount();
|
||||
}
|
||||
return $balance;
|
||||
}
|
||||
}
|
||||
|
||||
Now we can always call `Account::getBalance()` to access the current account balance.
|
||||
|
||||
To enforce the max credit rule we have to implement the "Aggregate Root" pattern as
|
||||
described in Eric Evans book on Domain Driven Design. Described with one sentence,
|
||||
an aggregate root controls the instance creation, access and manipulation of its children.
|
||||
|
||||
In our case we want to enforce that new entries can only added to the `Account` by
|
||||
using a designated method. The `Account` is the aggregate root of this relation.
|
||||
We can also enforce the correctness of the bi-directional `Account` <-> `Entry`
|
||||
relation with this method:
|
||||
|
||||
[php]
|
||||
class Account
|
||||
{
|
||||
public function addEntry($amount)
|
||||
{
|
||||
$this->assertAcceptEntryAllowed($amount);
|
||||
|
||||
$e = new Entry($e, $amount);
|
||||
$this->entries[] = $e;
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
|
||||
Now look at the following test-code for our entities:
|
||||
|
||||
[php]
|
||||
class AccountTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
public function testAddEntry()
|
||||
{
|
||||
$account = new Account("123456", $maxCredit = 200);
|
||||
$this->assertEquals(0, $account->getBalance());
|
||||
|
||||
$account->addEntry(500);
|
||||
$this->assertEquals(500, $account->getBalance());
|
||||
|
||||
$account->addEntry(-700);
|
||||
$this->assertEquals(-200, $account->getBalance());
|
||||
}
|
||||
|
||||
public function testExceedMaxLimit()
|
||||
{
|
||||
$account = new Account("123456", $maxCredit = 200);
|
||||
|
||||
$this->setExpectedException("Exception");
|
||||
$account->addEntriy(-1000);
|
||||
}
|
||||
}
|
||||
|
||||
To enforce our rule we can now implement the assertion in `Account::addEntry`:
|
||||
|
||||
[php]
|
||||
class Account
|
||||
{
|
||||
private function assertAcceptEntryAllowed($amount)
|
||||
{
|
||||
$futureBalance = $this->getBalance() + $amount;
|
||||
$allowedMinimalBalance = ($this->maxCredit * -1);
|
||||
if ($futureBalance < $allowedMinimalBalance) {
|
||||
throw new Exception("Credit Limit exceeded, entry is not allowed!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
We haven't talked to the entity manager for persistence of our account example before.
|
||||
You can call `EntityManager::persist($account)` and then `EntityManager::flush()`
|
||||
at any point to save the account to the database. All the nested `Entry` objects
|
||||
are automatically flushed to the database also.
|
||||
|
||||
[php]
|
||||
$account = new Account("123456", 200);
|
||||
$account->addEntry(500);
|
||||
$account->addEntry(-200);
|
||||
$em->persist($account);
|
||||
$em->flush();
|
||||
|
||||
The current implementation has a considerable drawback. To get the balance, we
|
||||
have to initialize the complete `Account::$entries` collection, possibly a very
|
||||
large one. This can considerably hurt the performance of your application.
|
||||
|
||||
## Using an Aggregate Field
|
||||
|
||||
To overcome the previously mentioned issue (initializing the whole entries collection)
|
||||
we want to add an aggregate field called "balance" on the Account and adjust the
|
||||
code in `Account::getBalance()` and `Account:addEntry()`:
|
||||
|
||||
[php]
|
||||
class Account
|
||||
{
|
||||
/**
|
||||
* @Column(type="integer")
|
||||
*/
|
||||
private $balance = 0;
|
||||
|
||||
public function getBalance()
|
||||
{
|
||||
return $this->balance;
|
||||
}
|
||||
|
||||
public function addEntry($amount)
|
||||
{
|
||||
$this->assertAcceptEntryAllowed($amount);
|
||||
|
||||
$e = new Entry($e, $amount);
|
||||
$this->entries[] = $e;
|
||||
$this->balance += $amount;
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
|
||||
This is a very simple change, but all the tests still pass. Our account entities return
|
||||
the correct balance. Now calling the `Account::getBalance()` method will not occour the
|
||||
overhead of loading all entries anymore. Adding a new Entry to the `Account::$entities`
|
||||
will also not initialize the collection internally.
|
||||
|
||||
Adding a new entry is therefore very performant and explictly hooked into the domain model.
|
||||
It will only update the account with the current balance and insert the new entry into the database.
|
||||
|
||||
## Tackling Race Conditions with Aggregate Fields
|
||||
|
||||
Whenever you denormalize your database schema race-conditions can potentially lead to
|
||||
inconsistent state. See this example:
|
||||
|
||||
[php]
|
||||
// The Account $accId has a balance of 0 and a max credit limit of 200:
|
||||
// request 1 account
|
||||
$account1 = $em->find('Bank\Entities\Account', $accId);
|
||||
|
||||
// request 2 account
|
||||
$account2 = $em->find('Bank\Entities\Account', $accId);
|
||||
|
||||
$account1->addEntry(-200);
|
||||
$account2->addEntry(-200);
|
||||
|
||||
// now request 1 and 2 both flush the changes.
|
||||
|
||||
The aggregate field `Account::$balance` is now -200, however the SUM over all
|
||||
entries amounts yields -400. A violation of our max credit rule.
|
||||
|
||||
You can use both optimistic or pessimistic locking to save-guard
|
||||
your aggregate fields against this kind of race-conditions. Reading Eric Evans
|
||||
DDD carefully he mentions that the "Aggregate Root" (Account in our example)
|
||||
needs a locking mechanism.
|
||||
|
||||
Optimistic locking is as easy as adding a version column:
|
||||
|
||||
[php]
|
||||
class Amount
|
||||
{
|
||||
/** @Column(type="integer") @Version */
|
||||
private $version;
|
||||
}
|
||||
|
||||
The previous example would then throw an exception in the face of whatever request
|
||||
saves the entity last (and would create the inconsistent state).
|
||||
|
||||
Pessimmistic locking requires an additional flag set on the `EntityManager::find()`
|
||||
call, enabling write locking directly in the database using a FOR UPDATE.
|
||||
|
||||
[php]
|
||||
use Doctrine\DBAL\LockMode;
|
||||
|
||||
$account = $em->find('Bank\Entities\Account', $accId, LockMode::PESSIMISTIC_READ);
|
||||
|
||||
## Keeping Updates and Deletes in Sync
|
||||
|
||||
The example shown in this article does not allow changes to the value in `Entry`,
|
||||
which considerably simplifies the effort to keep `Account::$balance` in sync.
|
||||
If your use-case allows fields to be updated or related entities to be removed
|
||||
you have to encapsulate this logic in your "Aggregate Root" entity and adjust
|
||||
the aggregate field accordingly.
|
||||
|
||||
## Conclusion
|
||||
|
||||
This article described how to obtain aggregate values using DQL or your domain model.
|
||||
It showed how you can easily add an aggregate field that offers serious performance
|
||||
benefits over iterating all the related objects that make up an aggregate value.
|
||||
Finally I showed how you can ensure that your aggregate fields do not get out
|
||||
of sync due to race-conditions and concurrent access.
|
Loading…
Reference in New Issue
Block a user