Cómo simplificar una relación Many-To-Many consigo misma

En esta breve entrada expongo cómo el programador puede facilitarse un poco su actividad cuando en Doctrine establezca una relación de una entidad consigo misma. El código que se expone es de una entidad del framework Symfony, pero fácilmente se puede adaptar a cualquier entorno PHP donde, eso sí, se esté empleando este conocido ORM.

Según la documentación de Doctrine, el código para la relación Many-To-Many (de muchos a muchos) de una entidad que se referencia a si misma, por ejemplo un producto que puede tener otros productos relacionados, sería:

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

    /**
     * @ManyToMany(targetEntity="Product", mappedBy="myProducts")
     */
    private $relatedWithMe;

    /**
     * @ManyToMany(targetEntity="Product", inversedBy="relatedWithMe", fetch="EAGER")
     * @JoinTable(name="related_products",
     *      joinColumns={@JoinColumn(name="product_id", referencedColumnName="id")},
     *      inverseJoinColumns={@JoinColumn(name="related_product_id", referencedColumnName="id")}
     *      )
     */
    private $myProducts;

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

    public function addRelatedWithMe(Product $relatedWithMe): self
    {
        if (!$this->relatedWithMe->contains($relatedWithMe)) {
            $this->relatedWithMe[] = $relatedWithMe;
            $relatedWithMe->addMyProduct($this);
        }

        return $this;
    }

    public function removeRelatedWithMe(Product $relatedWithMe): self
    {
        if ($this->relatedWithMe->contains($relatedWithMe)) {
            $this->relatedWithMe->removeElement($relatedWithMe);
            $relatedWithMe->removeMyProduct($this);
        }

        return $this;
    }

    /**
     * @return Collection|Product[]
     */
    public function getMyProducts(): ?Collection
    {
        return $this->myProducts;
    }

    public function addMyProduct(Product $myProduct): self
    {
        if (!$this->myProducts->contains($myProduct)) {
            $this->myProducts[] = $myProduct;
        }

        return $this;
    }

    public function removeMyProduct(Product $myProduct): self
    {
        if ($this->myProducts->contains($myProduct)) {
            $this->myProducts->removeElement($myProduct);
        }

        return $this;
    }

Esta estructura con dos propiedades presenta un inconveniente: las consultas a la base de datos para buscar todos los productos relacionados pueden ser algo más complejas pues si, por ejemplo, el producto 1 está relacionado con el 2, el ser bidireccional implica que el 2 también lo está con el 1, pero en la tabla pivote related_products sólo tendremos el registro (1, 2) o el (2, 1).

Una solución que nos permite mantener las consultas a la base de datos sencillas y un código PHP nítido, es crear una relación unidireccional con un “truco” o hack de tan sólo dos líneas:

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

    /**
     * @ManyToMany(targetEntity="Product")
     * @JoinTable(name="related_products",
     *     joinColumns={@JoinColumn(name="product_id", referencedColumnName="id")},
     *     inverseJoinColumns={@JoinColumn(name="related_product_id", referencedColumnName="id")}
     *      )
     */
    private $relatedProducts;

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

    /**
     * @return array
     */
    public function getRelatedProducts()
    {
        return $this->relatedProducts->toArray();
    }

    /**
     * @param  Product $product
     * @return void
     */
    public function addRelatedProduct(Product $product)
    {
        if (!$this->relatedProducts->contains($product)) {
            $this->relatedProducts->add($product);
            $product->addRelatedProduct($this);
        }
    }

    /**
     * @param  Product $product
     * @return void
     */
    public function removeRelatedProduct(Product $product)
    {
        if ($this->relatedProducts->contains($product)) {
            $this->relatedProducts->removeElement($product);
            $product->removeRelatedProduct($this);
        }
    }

La primera línea marcada en negrita es la que inserta el segundo registro en la base de datos; de manera que para consultar los productos relacionados de un producto no tendremos que consultar dos propiedades. El precio a pagar será que en la tabla tendremos datos que podrían considerarse duplicados, pero hará la programación más sencilla y, de todas formas, con un ORM no se puede ser muy escrupuloso con las formas normales.

Esta idea también podría aplicarse al ejemplo inicial, pero si añadimos ambos registros para simplificar las consultas, entonces nos encontramos con que se añade complejidad a la clase pues ambas propiedades, $myProducts y $relatedWithMe, se deberían mantener sincronizadas y actualizadas.

Por lo tanto, esta relación unidireccional de una entidad consigo misma, aunque no aparece en la documentación oficial, es una solución que nos permite crear un código más claro y más fácil de mantener.