Symfony & EAV: cómo diseñar una BD para no tener que modificarla después

Estoy haciendo una especie de experimento con Symfony, se trata de un codekata para usar el modelo EAV. No he encontrado en Internet nada hecho en Symfony que hiciera exactamente lo siguiente, todo desde un panel de control, y quería aprender a crearlo. Así que aquí estoy, jugando con la programación, y compartiendo este codekata 😉 Me explico, viendo cómo están hechas por dentro la gestión de productos en CMSs como Magento, Prestashop o Drupal.. o en ERPs como Odoo.. vemos que internamente, podemos dar de alta tantos atributos de producto como queramos desde el panel de control.

¡Podemos dar de alta tantos atributos como queramos sin modificar la base de datos! ¿Cómo es esto por dentro que funciona? ¿No te ha pasado que mientras desarrollabas una aplicación tenías que añadir más y más columnas a una tabla? Si la cantidad de atributos va a ser muy cambiante, la forma más profesional y eficiente para programarlo se consigue usando el modelo EAV.

Empezamos, un poco de teoría, pero poco xD

El siguiente ensayo es para trabajar información sobre Websites, siguiendo esta estructura de datos llamada modelo EAV, Entity-Attribute-Value model en inglés. Es a la vez un modelo de datos y una técnica de programación, no es un patrón de diseño en sí mismo. Es para hacer que tu aplicación sea lo más dinámica posible, dándote los siguientes puntos positivos:

  • Un buen diseño de la BD usando este modelo, hará que no tengas que modificar la BD mientras que añade funcionalidades programadas o modificas las existentes.
  • El almacenamiento en la BD será lo más eficiente posible, ya que te permite sólo grabar los atributos cuando tengan un valor.
  • Estos atributos podrás administrarlos desde un panel de control.
  • Una vez hecho el diseño de la BD y el panel de administración de los atributos, no tendrás que reprogramar esta parte de la aplicación.

Como puntos negativos tendrá:

  • Es complejo para empezar, pero más te valdrá haberlo hecho si luego lo necesitas.
  • Las consultas a la BD son más pesadas.
  • Tendrás que crear código intermedio que maneje estos atributos por ti.

En el modelo EAV (Entity Attribute Value) tenemos 3 tipo de tablas. Para el caso del post tenemos la entidad Website que se define con atributos dinámicos. Para cada entidad que queramos tener así, tendremos este trío de tablas.. en donde, simplificando al absurdo, tendremos:

  • Una tabla que define los atributos.
  • Una tabla que define las entidades.
  • Una o más tablas que almacenarán los valores de las tablas.

La idea para este post es que definimos los atributos en la tabla EavAttribute, en WebsiteEntity damos de alta los websites, y luego según el tipo de atributo, almacenamos en la tabla que corresponde cada valor (WebsiteString, WebsiteText, etc..). Con Doctrine en Symfony quedarán entidades parecidas a la siguiente imagen:

Será más fácil leer el código para comprender su funcionamiento. Al grano..

Creando el proyecto desastre

Como viene siendo costumbre en este blog, vamos a la línea de comandos y escribimos:

composer create-project symfony/skeleton symfony-building-eav
cd symfony-building-eav
composer require --dev maker profiler
composer require doctrine twig form validator security-csrf annotations

Si todo ha ido bien, ya podemos arrancar el proyecto en local, y comenzamos a programar:

symfony server:start

Dejo todo el código fuente resultado aquí, pero si tu idea es seguir paso a paso el proceso en tu ordenador, sigue leyendo el post…

Definiendo la tabla de atributos y la de entidades

Lo siguiente será definir la entidad para guardar los atributos y la entidad de los websites. En este codekata tenemos los tipos de atributos: string, text, boolean, integer, float y datetime. Para la entidad EavAttribute tendremos los siguiente con Doctrine:

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $description;

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

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $type;

Para la entidad principal, le he nombrado WebsiteEntity, y mediante Doctrine nos quedarán los siguientes atributos:

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $domain;

    /**
     * @ORM\Column(type="boolean")
     */
    private $https;

Tenemos que ir a línea de comandos y lanzar lo siguiente para crear estas dos entidades:

php bin/console make:entity EavAttribute
php bin/console make:entity WebsiteEntity

..siguiendo las intrucciones por pantalla nos irá preguntando para hacer una y otra entidad, con todos sus atributos.

Definiendo las tablas para los valores de los atributos

Lo siguiente será definir dichas tablas. Como la entidad la hemos nombrado WebsiteEntity, entonces, para los 6 tipos de atributos posibles que he elegido, les he nombrado a cada entidad:

  • WebsiteString
  • WebsiteText
  • WebsiteBoolean
  • WebsiteInteger
  • WebsiteFloat
  • WebsiteDatetime

Todas estas tablas/entidades, tendrán el siguiente atributo de Doctrine:

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $value;

Mira que para la tabla WebsiteString será de tipo string. Para la tabla WebsiteText será de tipo text. Y así sucesivamente..

Así que igualmente desde línea de comandos y siguiendo la interacción con Symfony, generamos los códigos fuentes en unos minutos con lo siguiente:

php bin/console make:entity WebsiteString
php bin/console make:entity WebsiteText
php bin/console make:entity WebsiteBoolean
php bin/console make:entity WebsiteInteger
php bin/console make:entity WebsiteFloat
php bin/console make:entity WebsiteDatetime

Dejo en el aire el cómo ir comando a comando, interactivamente con Symfony, creando cada entidad. Me remito a un post anterior por si quieres revisar cómo se trabaja con el generador de entidades haciendo click aquí.

Definiendo las relaciones

Para las relaciones y simplificar así el trabajo diario, he pensado en las siguientes. Cada EavAttribute, tendrá una relación OneToMany que referencia a los valores. Tras usar el generador de códigos, desde línea de comandos, nos tiene que quedar así o parecido:

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteBoolean", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteBooleans;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteDatetime", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteDatetimes;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteFloat", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteFloats;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteInteger", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteIntegers;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteString", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteStrings;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteText", mappedBy="eavAttribute", cascade={"remove"})
     */
    private $websiteTexts;

Igualmente, podemos definir una relación OneToMany, para que desde una entidad WebsiteEntity, tengamos enlazados los valores relacionados. Para esto debería quedar algo como lo siguiente en la entidad WebsiteEntity:

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteString",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $strings;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteText",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $texts;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteBoolean",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $booleans;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteInteger",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $integers;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteFloat",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $floats;

    /**
     * @ORM\OneToMany(targetEntity="App\Entity\WebsiteDatetime",
     * mappedBy="websiteEntity",
     * cascade={"persist","remove"})
     */
    private $datetimes;

..son iguales las relaciones entre EavAttributes y los atributos, y entre WebsiteEntity y los atributos.

La siguiente relación, es para cada atributo, en las tablas de almacén de valores, necesitaremos saber a qué Website y a qué Attributo corresponde. Así que cada una tendrá las siguientes relaciones del tipo ManyToOne. Las siguientes relaciones se generan con el generador en minutos desde línea de comandos:

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\WebsiteEntity", inversedBy="strings", cascade={"persist"})
     * @ORM\JoinColumn(nullable=false)
     */
    private $websiteEntity;

    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\EavAttribute", inversedBy="websiteStrings")
     * @ORM\JoinColumn(nullable=false)
     */
    private $eavAttribute;

Lo mismo será para el tipo de datos text, boolean, integer, float y datetime.

Creando la base datos

A partir de aquí, si todo ha ido bien, lo siguiente es configurar el fichero .env en el proyecto con la BD destino que quieras y lanzar lo siguiente:

php bin/console doctrine:database:create
php bin/console doctrine:schema:create

Creando unos CRUDs para gestionar los atributos y entidades

Para esto tenemos que usar de nuevo la línea de comandos:

php bin/console make:crud EavAttribute
php bin/console make:crud WebsiteEntity

..esto nos generará el código fuente y podremos acceder localmente a gestionar en http://localhost:8000/eav/attribute/ y http://localhost:8000/website/entity/

Lo especial de gestionar entidades con el modelo EAV

Lo diferente de usar el modelo EAV, lo tendremos al grabar o leer datos de atributos EAV. Para esto necesitaremos de código fuente intermedio para gestionarlo, esta es la idea. Me remito al código fuente compartido arriba en el .zip. En el servicio llamado WebsiteManager en el fichero src/Service/WebsiteManager.php dejo una lógica posible para grabar o leer estos atributos:

<?php

namespace App\Service;

use Doctrine\Common\Persistence\ObjectManager;

class WebsiteManager
{
    private $entityManager;

    public function __construct(ObjectManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function setEavValue($website, $code, $value)
    {
        /*var_dump($website->getId());
        var_dump($code);
        var_dump($value);*/
        $eavAttribute = $this->entityManager->getRepository(\App\Entity\EavAttribute::class)
            ->findOneByCode($code);

        if ($eavAttribute) {
            if($eavAttribute->getType()=='datetime') {
                $value = new \DateTime($value['date']['year'].'-'
                    .$value['date']['month'].'-'
                    .$value['date']['day'].' '
                    .$value['time']['hour'].':'
                    .$value['time']['minute'].''
                );
            }

            $theGetter = 'get'.ucfirst($eavAttribute->getType()).'s';
            $theType = '\App\Entity\Website'.ucfirst($eavAttribute->getType());

            $found = false;
            foreach ($website->$theGetter() as $attr) {
                if ($attr->getEavAttribute()->getCode() == $code) {
                    $attr->setEavValue($value);
                    $found = true;
                }
            }

            if (!$found) {
                $newAttr = new $theType();
                $newAttr->setEavValue($value);
                $newAttr->setWebsiteEntity($website);
                $newAttr->setEavAttribute($eavAttribute);

                $theAddFunction = 'add'.ucfirst($eavAttribute->getType());
                $website->$theAddFunction($newAttr);
            }
        }
    }

    public function getEavValue($website, $code)
    {
        $eavAttribute = $this->entityManager->getRepository(\App\Entity\EavAttribute::class)
            ->findOneByCode($code);

        $theType = 'Website'.ucfirst($eavAttribute->getType());
        $sql = 'SELECT wt.value '
            .'FROM App:'.$theType.' wt '
            .'WHERE wt.websiteEntity = '.$website->getId().' '
            .'AND wt.eavAttribute = '.$eavAttribute->getId();

        $result = $this->entityManager->createQuery($sql)->getResult();
        if (!empty($result)) {
            $value = $result[0]['value'];
        } else {
            $value = '';
        }

        return $value;
    }
}

Otro punto en donde he cambiado el código que te genera Symfony, es el formulario src/Form/WebsiteEntityType.php que es el siguiente:

<?php

namespace App\Form;

use App\Entity\WebsiteEntity;
use App\Service\WebsiteManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;

class WebsiteEntityType extends AbstractType
{
    private $entityManager;
    private $websiteManager;

    public function __construct(ObjectManager $entityManager, WebsiteManager $websiteManager)
    {
        $this->entityManager = $entityManager;
        $this->websiteManager = $websiteManager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('domain')
            ->add('https')
        ;

        $entity = $builder->getData();

        $eavAttributes = $this->entityManager->getRepository(\App\Entity\EavAttribute::class)->findAll();
        foreach ($eavAttributes as $eavAttribute) {
            switch ($eavAttribute->getType()) {
                case 'string':
                    $builder->add(
                        $eavAttribute->getCode(),
                        TextType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                case 'text':
                    $builder->add(
                        $eavAttribute->getCode(),
                        TextareaType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                case 'boolean':
                    $builder->add(
                        $eavAttribute->getCode(),
                        CheckboxType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                case 'integer':
                    $builder->add(
                        $eavAttribute->getCode(),
                        IntegerType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                case 'float':
                    $builder->add(
                        $eavAttribute->getCode(),
                        NumberType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                case 'datetime':
                    $builder->add(
                        $eavAttribute->getCode(),
                        DateTimeType::class,
                        [
                            'mapped' => false,
                            'required' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;
                /*default:
                    $builder->add(
                        $eavAttribute->getCode(),
                        null,
                        [
                            'mapped' => false,
                            'requirequired' => false,
                            'data' => $this->websiteManager->getEavValue($entity, $eavAttribute->getCode())
                        ]
                    );
                    break;*/
            }

        }
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => WebsiteEntity::class,
        ]);
    }
}

Lo único que hace es pintar las cajas de los atributos que hayas definido. Es decir, en el momento en que das de alta un atributo en el panel de control, el formulario, al vuelo, se genera con la caja de edición del tipo que corresponde para cada nuevo atributo.

Te invito a que mires también en el guardado, en la función edit del fichero src/Controller/WebsiteEntityController.php, porque hay que modificarlo. En el guardado de este formulario, lo que hacemos es recibir tanto el WebsiteEntity con sus valores fijos, como todos los valores para los atributos EAV. Un posible código fuente para guardar es:

    /**
     * @Route("/{id}/edit", name="website_entity_edit", methods={"GET","POST"})
     */
    public function edit(Request $request, WebsiteEntity $websiteEntity, WebsiteManager $websiteManager): Response
    {
        $form = $this->createForm(WebsiteEntityType::class, $websiteEntity);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();
            $formParameters = $request->request->all();
            foreach ($formParameters as $entities) {
                foreach ($entities as $key => $value) {
                    //var_dump($value);
                    if ('domain' != $key and 'https' != $key) {
                        var_dump('Saving '.$key.'..');
                        $websiteManager->setEavValue($websiteEntity, $key, $value);
                    }
                }
            }

            $this->getDoctrine()->getManager()->flush();

            return $this->redirectToRoute('website_entity_edit', ['id' => $websiteEntity->getId()]);
        }

        return $this->render('website_entity/edit.html.twig', [
            'website_entity' => $websiteEntity,
            'form' => $form->createView(),
        ]);
    }

..y ya me estoy extendiendo demasiado.. 😉 Dejo todo el código funcional en el fichero .zip enlazado arriba. Sólo necesita ejecutar Composer y arrancar el servidor en local para verlo funcionar.

¡Un saludo! Otro día más..

Compartir..

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *