Aggregate Fields

You will often come across the requirement to display aggregate values of data that can be computed by using the MIN, MAX, COUNT or SUM SQL functions. For any ORM this is a tricky issue traditionally. Doctrine ORM offers several ways to get access to these values and this article will describe all of them from different perspectives.

MIN、MAX、COUNT、または SUM SQL 関数を使用して計算できるデータの集計値を表示する必要がある場合がよくあります。 ORM にとって、これは伝統的に難しい問題です。 Doctrine ORM はこれらの値にアクセスするためのいくつかの方法を提供しており、この記事ではそれらすべてをさまざまな観点から説明します。

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 where 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.

簡単にするために、私たちはお金が整数のみで構成される世界に住んでいます。また、受取人・差出人の氏名、記載の振込理由、執行年月日は省略しております。これらはすべて Entry オブジェクトに追加する必要があります。

Our entities look like:

エンティティは次のようになります。

<?php

namespace Bank\Entities;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Account
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private ?int $id;

    /**
     * @ORM\Column(type="string", unique=true)
     */
    private string $no;

    /**
     * @ORM\OneToMany(targetEntity="Entry", mappedBy="account", cascade={"persist"})
     */
    private array $entries;

    /**
     * @ORM\Column(type="integer")
     */
    private int $maxCredit = 0;

    public function __construct(string $no, int $maxCredit = 0)
    {
        $this->no = $no;
        $this->maxCredit = $maxCredit;
        $this->entries = new \Doctrine\Common\Collections\ArrayCollection();
    }
}

/**
 * @ORM\Entity
 */
class Entry
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private ?int $id;

    /**
     * @ORM\ManyToOne(targetEntity="Account", inversedBy="entries")
     */
    private Account $account;

    /**
     * @ORM\Column(type="integer")
     */
    private int $amount;

    public function __construct(Account $account, int $amount)
    {
        $this->account = $account;
        $this->amount = $amount;
        // more stuff here, from/to whom, stated reason, execution date and such
    }

    public function getAmount(): Amount
    {
        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:

Doctrine クエリ言語を使用すると、ドメイン モデルのフィールドから計算された集計値を選択できます。次のように呼び出して、アカウントの現在の残高を選択できます。

<?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.

この (および今後の) 例の $em 変数は、Doctrine EntityManager を保持します。すべての金額の合計 (負の金額は引き出し) のクエリを作成し、それらを単一のスカラー結果として取得し、基本的に最初の行の最初の列のみを返します。

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.

また、追加の要件は、Accountrule ごとの最大クレジットです。

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.

残高の DQL 取得を使用して Account エンティティにこのルールを確実に適用することはできません。アカウントを取得するには、さまざまな方法があります。ユーザーランド プログラマーが新しく追加されたエントリに対してこのバランスをチェックすることは言うまでもなく、これらすべてのユース ケースに対して集計クエリを実行できることを保証することはできません。

Using your Domain Model

Account and all the Entry instances are connected through a collection, which means we can compute this value at runtime:

Account とすべての Entry インスタンスは、コレクションを介して接続されます。つまり、実行時にこの値を計算できます。

<?php
class Account
{
    // .. previous code

    public function getBalance(): int
    {
        $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.

これで、いつでも Account::getBalance() を呼び出して、現在の口座残高にアクセスできます。

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.

最大クレジット ルールを適用するには、"AggregateRoot" パターンを実装する必要があります。 1 つの文で説明すると、集約ルートはインスタンスの作成、アクセス、およびその子の操作を制御します。

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:

私たちの場合、指定された方法を使用してのみ新しいエントリをアカウントに追加できるようにしたいと考えています。アカウントは、この関係の集約ルートです。このメソッドを使用して、双方向の AccountEntry 関係の正確性を強制することもできます。

<?php
class Account
{
    public function addEntry(int $amount): void
    {
        $this->assertAcceptEntryAllowed($amount);

        $e = new Entry($this, $amount);
        $this->entries[] = $e;
    }
}

Now look at the following test-code for our entities:

次に、エンティティの次のテスト コードを見てください。

<?php

use PHPUnit\Framework\TestCase;

class AccountTest extends 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->expectException(Exception::class);
        $account->addEntry(-1000);
    }
}

To enforce our rule we can now implement the assertion in Account::addEntry:

ルールを適用するために、アサーション inAccount::addEntry: を実装できるようになりました。

<?php

class Account
{
    // .. previous code

    private function assertAcceptEntryAllowed(int $amount): void
    {
        $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.

前に、アカウントの例の永続性についてエンティティ マネージャーと話したことはありません。いつでも EntityManager::persist($account) を呼び出してから EntityManager::flush() を呼び出して、アカウントをデータベースに保存できます。ネストされたすべてのエントリ オブジェクトも、データベースに自動的にフラッシュされます。

<?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.

現在の実装にはかなりの欠点があります。残高を得るには、完全な Account::$entries コレクションを初期化する必要があります。これは非常に大きなものになる可能性があります。これにより、アプリケーションのパフォーマンスが大幅に低下する可能性があります。

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():

前述の問題 (wholeentries コレクションの初期化) を克服するために、Account に「balance」という集計フィールドを追加し、Account::getBalance() と Account:addEntry() のコードを調整します。

<?php
class Account
{
    /**
     * @ORM\Column(type="integer")
     */
    private int $balance = 0;

    public function getBalance(): int
    {
        return $this->balance;
    }

    public function addEntry(int $amount): void
    {
        $this->assertAcceptEntryAllowed($amount);

        $e = new Entry($this, $amount);
        $this->entries[] = $e;
        $this->balance += $amount;
    }
}

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 occur the overhead of loading all entries anymore. Adding a new Entry to the Account::$entities will also not initialize the collection internally.

これは非常に単純な変更ですが、すべてのテストは引き続きパスします。 Ouraccount エンティティは正しい残高を返します。 Account::getBalance() メソッドを呼び出しても、すべてのエントリをロードするオーバーヘッドは発生しなくなりました。新しいエントリを theAccount::$entities に追加しても、コレクションは内部的に初期化されません。

Adding a new entry is therefore very performant and explicitly 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

use Bank\Entities\Account;

// The Account $accId has a balance of 0 and a max credit limit of 200:
// request 1 account
$account1 = $em->find(Account::class, $accId);

// request 2 account
$account2 = $em->find(Account::class, $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.

集計フィールド Account::$balance は現在 -200 ですが、すべてのエントリ金額の合計は -400 になります。 maxcredit ルールの違反。

You can use both optimistic or pessimistic locking to safe-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.

楽観的ロックまたは悲観的ロックの両方を使用して、この種の競合状態から集計フィールドを保護できます。 Eric Evans DDD を注意深く読んで、「集約ルート」(この例ではアカウント) にはロック機構が必要であると述べています。

Optimistic locking is as easy as adding a version column:

オプティミスティック ロックは、バージョン カラムを追加するのと同じくらい簡単です。

<?php

class Account
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Version
     */
    private int $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).

前の例では、最後にエンティティを保存する要求に直面して例外がスローされます (そして、一貫性のない状態が作成されます)。

Pessimistic locking requires an additional flag set on the EntityManager::find() call, enabling write locking directly in the database using a FOR UPDATE.

悲観的ロックでは、EntityManager::find() 呼び出しに追加のフラグを設定する必要があり、FOR UPDATE を使用してデータベースで直接書き込みロックを有効にします。

<?php

use Bank\Entities\Account;
use Doctrine\DBAL\LockMode;

$account = $em->find(Account::class, $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.

この記事で示した例では、Entry の値を変更することはできません。これにより、Account::$balance の同期を維持する作業が大幅に簡素化されます。ユースケースでフィールドの更新または関連エンティティの削除が許可されている場合は、このロジックを「集約ルート」エンティティにカプセル化し、それに応じて集約フィールドを調整する必要があります。

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.

この記事では、DQL またはドメイン モデルを使用して集計値を取得する方法について説明しました。集計値を構成するすべての関連オブジェクトを反復処理するよりも、大幅なパフォーマンス上の利点を提供する集計フィールドを簡単に追加する方法を示しました。最後に、競合状態や同時アクセスが原因で集計フィールドが非同期にならないようにする方法を示しました。

Table Of Contents

Previous topic

35. Security

35. セキュリティ

Next topic

Custom Mapping Types

カスタム マッピング タイプ

This Page

Fork me on GitHub