Doctrine - Many-to-Many - Manuelles Ordnen der Beziehung

, Doctrine, Sortierung, Many-to-Many, Mapping, Entities

Zunächst mal möchte ich anmerken, dass ich hiermit Neuland betrete, denn dies ist sozusagen mein erster Blog-Post. Obwohl der besagte Blog, besser beschrieben die Blog-Funktionalität, hier noch gar nicht von mir implementiert wurden, möchte ich trotzdem die Gunst der Stunde nutzen und über ein heikles Thema schreiben, welches so manchen durchaus auch einmal im privaten oder beruflichen Umfeld begegnen könnte.

Worum geht es überhaupt?

Um zu verstehen, wo denn genau der Schuh drückt, sollte man vertraut mit den Assoziationen der Doctrine Entitäten sein. Sie bilden eines der mächtigen Werkzeuge, die einem der ORM an die Hand gibt. Daraus ergeben sich Situationen, die durch die Anforderung entstanden sind, in denen man zwei Entitäten mittels einer N zu M Relation verbinden möchte. Dazu nehmen wir im folgenden an, die Anforderung verlangt die Umsetzung einer Sidebar, die beliebig viele Sections haben kann. Da wir auch beliebig viele Sidebars haben können und keine Redundanzen verursachen wollen, kommen wir auf die N zu M Relation der beiden Entitäten. Des weiteren, und jetzt kommt der eigentliche Knackpunkt, soll es möglich sein die Sections innerhalb einer Sidebar beliebig zu ordnen. Also hat die Positionsangabe der Relation nichts in der Entität der Section selbst, und noch weniger in der Sidebar Entität etwas verloren. Diese Positionsangabe sollte in der Kreuztabelle angesiedelt werden. Und damit stecken wir mitten im Dilemma, denn genau um diese Kreuztabelle sollte sich doch eigentlich Doctrine kümmern, um uns die Arbeit mit den Relationen abzunehmen. Leider kann Doctrine genau diese Anforderung nicht erfüllen, jedenfalls nicht über die reine Annotation-Konfiguration über zwei Entitäten. Ein Lösungsansatz ist der des Join Entity, und genau diesen werde ich in diesem Beitrag näher beschreiben. Er wird aktuell von den Doctrine Entwicklern favorisiert 1 und ist recht einfach umzusetzen.

Die Ausgangssituation

Lange Rede, kurzer Sinn. Da ich nun schon einige Worte zum theoretischen Problem verloren habe, möchte ich nun etwas Code zeigen, der die Ausgangssituation verdeutlichen soll. Der folgende Schnipsel hat natürlich keinen Anspruch auf Vollständigkeit, und somit spiegelt er nur die Teile wieder, die ich im weiteren Verlauf näher beleuchten werde.

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebar")
 */
class Sidebar
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Section", inversedBy="sidebars")
     * @ORM\JoinTable(name="sidebars_sections")
     **/
    protected $sections;
}

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebar_section")
 */
class Section
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Sidebar", mappedBy="sections")
     **/
    protected $sidebars;
}

Wie sich unschwer erkennen lässt, habe ich beide Entitäten Sidebar und Section im Schnipsel abgebildet. Eine ganz übliche Many-to-Many Assoziation, die über die Annotations der Klasseneigenschaften abgewickelt wird. Dieses Beispiel würde Doctrine problemlos bewältigen können, indem es eine selbstverwaltete Kreuztabelle anlegen würde. Diese Kreuztabelle beschreiben wir in der Sidebar Klasse, welche die Owning Side der Assoziation darstellt, mit dem @JoinTable Tag in der Annotation. Dabei vergeben wir den Kreuztabellennamen und um den Rest kümmert sich Doctrine selbst. Soweit - so gut. Doctrine macht dem Entwickler an dieser Stelle das Leben leicht, würde es nicht noch den Wunsch nach der manuellen Sortierung geben.

Der Lösungsansatz

Jetzt sind wir mit der üblichen Problembewältigung vertraut und wer Doctrine schon des Öfteren verwendet hat, dem wird diese Herangehensweise bekannt sein. Denn genau das ist man von Doctrine gewöhnt, ein klarer, natürlicher Prozess der Strukturierung der Entitäten und deren Nutzung. Aus diesem Grund habe ich auch viel Zeit und Aufwand in die Recherche des von mir hier beschriebenen Ansatzes gesteckt, gerade weil sich das, was ich nun näher bringe, für Doctrine-Verhältnisse dreckig anfühlt. Nichtsdestotrotz bringt der Weg des Join Entity einige Vorteile mit sich, die durch die nachfolgenden Schnipsel ersichtlich werden. Doch zunächst bedarf es noch etwas Theorie. Was ist denn ein Join Entity? Ein Join Entity ist nichts weiter als eine dritte Entität, welche die Kreuztabelle in Form einer Klasse abbildet. Wer aufgepasst hat, wird jetzt merken, warum sich das ganze “dreckig” anfühlt. Wir nehmen mit dieser Vorgehensweise Doctrine in einem gewissen Teil die Arbeit weg, und genau das wollen wir eigentlich nicht. Wir nutzen doch den ORM, um uns die lästige Verwaltung der Relationen vom Hals zu halten. Doch in diesen Anwendungsfall sind uns die Hände gebunden. Wie sieht das ganze denn nun aus?

use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebar")
 */
class Sidebar
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\OneToMany(
     *      targetEntity="SidebarSection",
     *      mappedBy="sidebar",
     *      cascade={"persist", "remove"},
     *      orphanRemoval=true
     *      )
     * @ORM\OrderBy({"position" = "ASC"})
     */
    protected $sidebarSection;
}

/**
 * @ORM\Entity
 * @ORM\Table(name="section")
 */
class Section
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\OneToMany(
     *      targetEntity="SidebarSection",
     *      mappedBy="section",
     *      cascade={"persist", "remove"},
     *      orphanRemoval=true
     *      )
     */
    protected $sidebarSection;
}

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebars_sections")
 * @ORM\Entity(repositoryClass="Gedmo\Sortable\Entity\Repository\SortableRepository")
 */
class SidebarSection
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @Gedmo\SortableGroup
     * @ORM\ManyToOne(
     *      targetEntity="Sidebar",
     *      inversedBy="sidebarSection",
     *      cascade={"persist"}
     * )
     * @ORM\JoinColumn(name="sidebar_id", referencedColumnName="id", nullable=false)
     **/
    protected $sidebar;

    /**
     * @ORM\ManyToOne(
     *      targetEntity="Section",
     *      inversedBy="sidebarSection",
     *      cascade={"persist"}
     * )
     * @ORM\JoinColumn(name="section_id", referencedColumnName="id", nullable=false)
     **/
    protected $section;

    /**
     * @Gedmo\SortablePosition
     * @ORM\Column(name="position", type="integer")
     */
    protected $position;
}

Was neu dazugekommen ist, ist nun die SidebarSection Entität. Sie splittet die Many-to-Many Assoziation in mehrere One-to-Many Assoziationen auf. Somit schlägt die Join Entity die Relation zwischen den Sidebars und den Sections. Der aufmerksame Leser hat vermutlich schon die Einbindung der Doctrine Extensions, in diesem Falle der Sortable Extension bemerkt, und somit den Vorteil der Join Entity erkannt. Denn durch deren Existenz lässt sich diese sehr nützliche Erweiterung nutzen. Sofern man die Erweiterung aktiviert und in den Annotations festgelegt hat, wird die Verwaltung der manuell festgelegten Positionen zum Kinderspiel. Und damit wäre die Anforderung eigentlich schon erfüllt.

Wie lässt sich das ganze komfortabel nutzen?

Um diese Frage zu beantworten, muss man sich zunächst klar machen, dass wir den natürlichen Fluss der Dinge geändert haben. Wir müssen uns nun selbst um die effiziente Nutzung der Entitäten kümmern, denn wo früher simple Getter und Setter Methoden genügten, brauchen wir jetzt schon etwas komplexere Konstrukte. Jedoch lassen sich auch unter diesen Umständen mit ein wenig Mehraufwand Getter und Setter Methoden in den Sidebar und Section Entitäten definieren, die sich wie ihre simpleren Verwandten nutzen lassen. Grundlegend wird ein einfacher Konstruktor in der Join Entity benötigt.

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebars_sections")
 * @ORM\Entity(repositoryClass="Gedmo\Sortable\Entity\Repository\SortableRepository")
 */
class SidebarSection
{
    /**
     * __construct
     *
     * @param Sidebar $sidebar
     * @param Section $section
     * @access public
     * @return void
     */
    public function __construct(Sidebar $sidebar, Section $section)
    {
        $this->sidebar = $sidebar;
        $this->section = $section;
    }
}

Nichts Wildes also. Klar, einfach und leicht verständlich. Er bildet die Grundlage für die Setter. Ich werde nachfolgend nur die Sidebar Entität definieren, da sich der Code zu über 90% mit dem der Section Entität gleicht, und es offensichtlich ist was sich dort ändern würde.

/**
 * @ORM\Entity
 * @ORM\Table(name="sidebar")
 */
class Sidebar
{
    /**
     * Add section
     *
     * @param Section $section
     * @return Sidebar
     */
    public function addSection(Section $section)
    {
        // Build new relation object to handle join entity correct
        $rel = new SidebarSection($this, $section);

        // Add relation object to collection
        $this->sidebarSection[] = $rel;

        return $this;
    }

    /**
     * Remove section
     *
     * @param Section $sections
     */
    public function removeSection(\Jity\HomepageBundle\Entity\Section $section)
    {
        // Iterate over all join entities to find the correct
        foreach ($this->sidebarSection as $rel) {
            if ($rel->getSection() === $section) {
                $this->sidebarSection->removeElement($rel);
                $rel->getSection()->removeSidebar($this);
            }
        }
    }

    /**
     * Get sections
     *
     * @return Doctrine\Common\Collections\Collection
     */
    public function getSections()
    {
        $collection = new \Doctrine\Common\Collections\ArrayCollection();

        foreach ($this->sidebarSection as $rel) {
            $collection->add($rel->getSection());
        }

        return $collection;
    }
}

Die Setter Methode ist eine schöne Art, die klasseninterne Relation nach außen hin sauber zu verbergen. Wer möchte denn schon immer ein zusätzliches, nutzloses SidebarSection Objekt instanziieren, nur um die Relation zu definieren? Somit lässt sich der Setter ganz wie üblich nutzen. Auch der Getter lässt sich dank der von ihm befüllten ArrayCollection wie gehabt nutzen. Somit bilden diese Methoden den Doctrine-üblichen Fluss wieder ab, und am Ende fühlt es sich schon ein ganzes Stück sauberer an.

Ich hoffe, ich konnte den Sachverhalt verständlich weitergeben, und bitte natürlich um Feedback. Falls Fehler gefunden werden, korrigiere ich diese selbstverständlich. Leider ist die Blog-Funktionalität noch nicht implementiert, wie eingangs erwähnt, darum können noch keine Kommentare verfasst werden. Aber man kann mich über das Kontaktformular erreichen, oder über Google+.

Vielen Dank an Sebastian Große, der den Text nochmal Korrektur gelesen hat.

  1. Antwort auf Stackoverflow auf die Frage zur besten Lösung des Problems

Nächster Artikel