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.

Preferencia de las operaciones según Casio

Por la preferencia de operadores, así resolvemos la siguiente operación:

O también:

Ahora bien, la Casio fx-83ES Plus opina diferente:

Casio Fx 82ES PLUS error por la preferencia de operaciones

En primer lugar calcula el paréntesis para, a continuación, multiplicarlo por el denominador, resultado que, finalmente, dividirá el numerador.

En cambio, curiosamente muestra el resultado correcto cuando expresamos la división como una fracción:

Si introducimos exactamente lo mismo que hemos visto en la primera imagen en la Casio fx-570SP X II, una calculadora de gama más alta, nos sigue mostrando el mismo resultado, 1, pero esta vez ella misma añade unos ilustrativos paréntesis que hacen el resultado correcto:

Casio fx 570SP X II

Si seguimos el camino que nos indica, es decir, si añadimos paréntesis, sí obtenemos el resultado esperado, en cualquiera de las dos calculadoras, por ejemplo:

En definitiva, el orden de los operadores conduce a la resolución que se ha descrito inicialmente. Ahora bien, en este tipo de operaciones, estas calculadoras no siguen la jerarquía matemáticamente correcta. De hecho, en el manual de la Casio fx-570SP II encontraremos el caso concreto del uso de paréntesis con omisión del signo de multiplicación, y en el de ambas la jerarquía de las operaciones.

Casio Fx 82ES PLUS operacion correcta

Aquí no hemos omitido el signo de multiplicación

Parece ser que, el acrónimo tan usado en informática: RTFM (Read The Fucking Manual) también aplica a ordenadores tan sencillos como una calculadora.