Aggregatsfelder mit Symfony und Doctrine

Dieser Beitrag handelt von einem Thema mit dem wir eigentlich schon oft zu tun hatten und auf die ein oder andere Weise implementiert haben. Und doch stellt es uns immer wieder neu vor die Frage wie man es eigentlich »richtig« macht, ganz abgesehen von Besonderheiten die jeder spezielle Fall mit sich bringt: Aggregatsfelder bzw. Aggregate Fields.

Was ist ein Aggregatsfeld?

Häufig werden sogenannte Aggregatsfunktionen wie SUM(), COUNT(), AVG() etc. verwendet, um Kennzahlen über eine bestimmte Teilmenge von Datensätzen einer Datenbanktabelle zu bilden. In folgendem DQL-Query ermittelt SUM() etwa die Gesamtsumme an Kosten, die für einen Job aufgelaufen sind:

SELECT SUM(w.costs) AS totalCosts
FROM Work w
WHERE w.job = :job

Angenommen es ist eine Suchfunktion nach Jobs gewünscht. In der Ergebnisdarstellung sollen unter anderem auch die aktuell aufgelaufenen Kosten jedes gefundenen Jobs angezeigt werden. Schnell gelangt man zum Schluss dass es aus Gründen der Performance sicherlich keine gute Idee ist die Gesamtkosten für jeden Job in der Ergebnismenge on-the-fly immer wieder neu zu kalkulieren. Spätestens dann, wenn es sich um eine Echtzeitsuche à la packagist.org handelt. Praktischer wäre es die Gesamtkosten aus einem simplen Zahlenfeld des Job-Models auszulesen:

/**
 * @ORMEntity()
 */
class Job
{
    // ...

    /**
    * @ORMColumn(type="decimal", scale=2)
    */
    protected $costs

    public function getCosts()
    {
        return $this->costs;
    }
}

Ein solches Feld wird als Aggregatsfeld bzw. Aggregate Field bezeichnet. Es ist ein Zwischenspeicher für das Ergebnis einer Aggregatsfunktion um die Berechnung nicht ständig neu auszuführen obwohl sich das Ergebnis nicht geändert hat.

Wie bleibt das Aggregatsfeld »in sync«?

Dank des Aggregatsfelds ist eine Leseoperation nun wie gewünscht leichtgewichtig. Entscheidend ist aber nun: Wie wird das Aggregatsfeld aktuell und konsistent gehalten? Sehen wir uns dazu das im bisherigen Beispiel angedeutete Datenmodell genauer an. Es gibt Jobs und Works mit einer 1:n-Relation. Die Job-Entity:

namespace OnemediaJobBundleEntity;

use DoctrineCommonCollectionsArrayCollection;

/**
 * @ORMEntity
 */
class Job
{
    // Id etc.

    /**
    * @ORMOneToMany(targetEntity="Work", mappedBy="job", cascade={"persist"}, orphanRemoval=true)
    */
    protected $works;

    /**
    * @ORMColumn(type="decimal", scale=2)
    */
    protected $costs

    public function __construct()
    {
        $this->works = new ArrayCollection();
    }

    public function getWorks()
    {
        return $this->works;
    }

    public function getCosts()
    {
        return $this->costs;
    }
}

Die Work-Entity:

namespace OnemediaJobBundleEntity;

/**
 * @ORMEntity
 */
class Work
{
    // Id etc.

    /**
     * @ORMManyToOne(targetEntity="Job", inversedBy="works")
     * @ORMJoinColumn(nullable=false)
     */
    protected $job;

    /**
     * @ORMColumn(type="decimal", scale=2)
     */
    protected $costs = 0;

    public function getJob()
    {
        return $this->job;
    }

    public function setJob(Job $job)
    {
        $this->job = $job;
    }

    public function getCosts()
    {
        return $this->costs;
    }

    public function setCosts($costs)
    {
        $this->costs = $costs;
    }
}

Lassen wir das Aggregatsfeld noch außer Acht, lässt sich eine neue Work-Entity wie folgt erstellen:

$work = new Work();
$work->setCosts(500);
$work->setJob($job);
$entityManager->persist($work);
$entityManager->flush();

Ziel ist es nun, das Aggregatsfeld Job::$costs immer mit der Summe aller zugehörigen Work-Kosten aktuell zu halten. Der Wert kann sich bei beim Hinzufügen, Editieren und Löschen einer assoziierten Work-Entity ändern. Dafür gibt es sicherlich wie in der Einleitung angedeutet verschiedene Möglichkeiten – und sicher haben wir in früheren Projekten schon etliche davon eingesetzt. Unsere aktuelle Lösung haben wir darauf ausgerichtet den Code möglichst sauber zu organisieren, so dass beispielsweise Änderungen am Job:$costs-Feld innerhalb der Job-Entity veranlasst werden und keine offene Schnittstelle der Form Job::setCosts() besteht, die es ermöglichen würde die Kosten völlig unabhängig der assoziierten Works zu setzen. Ein Job muss daher darüber Bescheid wissen, wenn sich an seinen assoziierten Works etwas verändert. Die Implementierung für das Hinzufügen und Entfernen einer Work gestaltet sich dabei recht einfach und führt nicht am Job vorbei:

class Job
{
    // ...

    public function addWork(Work $work)
    {
        $work->setJob($this);
        $this->works->add($work);
        $this->adjustCosts($work->getCosts());
    }

    public function removeWork(Work $work)
    {
        $this->works->removeElement($work);
        $this->adjustCosts(-$work->getCosts());
    }

    private function adjustCosts($amount)
    {
        $this->costs += $amount;
    }
}

Komplizierter wird es beim Updaten einer Work-Entity, da das Aggregatsfeld um die Differenz aus den aktuell gespeicherten Work-Kosten und den neu zu speichernden Kosten angepasst werden muss. Im Code der Job-Entity ausgedrückt:

class Job
{
    // ...

    public function updateWork(Work $work)
    {
        if ($work->getCosts() != $work->getOriginalCosts()) {
            $this->adjustWorkCosts($work->getCosts() - $work->getOriginalCosts());
        }
    }
}

Es bleibt die Frage wie die Work-Entity an die aktuell in der Datenbank gespeicherten Kosten gelangt, da $work->getCosts() die neu zu speichernden Kosten zurückgibt – etwa aus einem abgeschickten Formular. In meiner Lösung habe ich den Lifecycle Callback PostLoad verwendet, um die aktuell persistenten Kosten beim Laden aus der Datenbank zwischen zu speichern. Diese lassen sich dann beim Abspeichern zur Differenzbildung nutzen.

/**
 * @ORMEntity()
 * @ORMHasLifecycleCallbacks()
 */
class Work
{
    // ...

    // Property to buffer original costs on entity load to have a
    // reference to the original costs while updating the job's
    // aggregate costs field
    private $originalCostsBuffer;

    public function getOriginalCosts()
    {
        return $this->originalCostsBuffer;
    }

     /**
     * @ORMPostLoad
     */
    public function bufferOriginalCosts()
    {
        $this->originalCostsBuffer = $this->costs;
    }
}

Da Insert-, Update- und Delete-Statements in Doctrine innerhalb der gleichen Flush-Operation standardmäßig als Transaktion ausgeführt werden, bleibt die Datenbank konsistent.

Zu diesem Thema haben wir bei der anfänglichen Recherche relativ wenige Informationen gefunden und selbst den passenden Cookbook-Eintrag der Doctrine-Dokumentation konnten wir nur als Ausgangspunkt verwenden, da er keine Updates behandelt.

Fazit

Die Überlegungen, Recherchen und verschiedenen Prototypen haben uns zu einer zufriedenstellenden Lösung geführt. Auf Grund der mangelnden Referenzen zu diesem Thema fehlt uns dennoch eine Bestätigung ob dieser Ansatz unseren selbst gesteckten »Clean Code«- und »Best Practice«-Anforderungen stand hält.

Welche Erfahrungen habt ihr mit diesem Thema gemacht und welche Lösungsansätze habt ihr verfolgt?

Leave a Reply

Your email address will not be published. Required fields are marked *