jueves, 5 de diciembre de 2013

Symfony 2 avanzado: Ordenando relaciones OneToMany (Doctrine)

En Doctrine, Las relaciones OneToMany son un mecanismo muy potente para acceder a una lista de objetos hijo. El ejemplo más claro de uso de las relaciones OneToMany sería una factura y las líneas de factura o un pedido y el detalle del pedido; y en general cualquier relación maestro-esclavo o padre-hijo en la que la clave foránea se encuentra en la tabla hija.

Así en los proyectos Symfony 2 que usan Doctrine obtener la lista de objetos hijo desde el padre es tan sencillo como acceder a la propiedad del objeto. Internamente Doctrine convierte eso en una query a la base de datos, pero el resultado obtenido por defecto no viene ordenado. Podemos garantizar el orden añadiendo la cláusula OrderBy a la declaración de la propiedad OneToMany, pero esa ordenación es muy limitada. Veamos un ejemplo:

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="facturas")
 */
class Factura
{
  /**
   * @var integer
   * 
   * @ORM\Id
   * @ORM\Column(name="id_factura", type="integer", nullable=false)
   */
  private $id;

  /**
   * @var \DateTime
   *
   * @ORM\Column(name="fac_fecha", type="text", nullable=false)
   */
  private $fecha;

  /**
   *
   * @ORM\OneToMany(targetEntity="Lineas", mappedBy="factura", cascade={"all"})
   * @ORM\OrderBy({"id" = "ASC"})
   */
  private $lineas;

  // ...
}

/**
 * @ORM\Entity()
 * @ORM\Table(name="lineas_factura")
 */
class Lineas
{
  /**
   * @var integer
   * 
   * @ORM\Id
   * @ORM\Column(name="id_linea", type="integer", nullable=false)
   */
  private $id;

  /**
   * @var \DateTime
   *
   * @ORM\Column(name="lin_fecha", type="text", nullable=false)
   */
  private $fecha;

  /**
   * @var integer
   * 
   * @ORM\ManyToOne(targetEntity="Factura", inversedBy="lineas")
   * @ORM\JoinColumn(name="id_factura", referencedColumnName="id_factura")
   */
  private $factura;

  /**
   * @var integer
   *
   * @ORM\OneToOne(targetEntity="Producto", cascade={"all"})
   * @ORM\JoinColumn(name="id_producto", referencedColumnName="id_producto")
   */
  private $producto;

  // ...
}

/**
 * @ORM\Entity()
 * @ORM\Table(name="productos")
 */
class Producto
{
  /**
   * @var integer
   * 
   * @ORM\Id
   * @ORM\Column(name="id_producto", type="integer", nullable=false)
   */
  private $id;

  /**
   * @var string
   *
   * @ORM\Column(name="prod_nombre", type="text", nullable=false)
   */
  private $nombre;

  /**
   * @var float
   *
   * @ORM\Column(name="prod_precio", type="float", nullable=false)
   */
  private $precio;

  // ...
}

En este ejemplo la propiedad lineas de la clase Factura tiene una cláusula OrderBy que hace que la lista resultado se obtenga ordenada por id. ¿Pero que ocurriría si quisiéramos que el resultado viniese ordenado por algún dato del producto asociado como el nombre o el precio? ¿No hay forma de hacerlo con Doctrine?

Pues no, no la hay. Pero algo se puede hacer: podríamos ordenar el resultado después de obtenerlo. Al fin y al cabo se trata de un simple array de objetos. ¿Pero como hacerlo para que esté disponible en toda la aplicación y para cualquier tipo de lista? Sencillo: Con un servicio y más concretamente con una extensión Twig.

class MyExtension extends \Twig_Extension
{
  public function getName()
  {
    return 'MyExtension';
  }

  public function getFilters()
  {
    return array(
      'sortCollection' => new \Twig_Filter_Method($this, 'sortCollection'),
    );
  }

  public function sortCollection($collection, $properties) {

    $objects = $collection->getValues();
    if($properties == null) {
      return $objects;
    }

    if(is_string($properties)) {
      $property = $properties;
      $properties = array( );
      $properties[] = $property;
    }

    if(is_array($properties)) {

      usort($objects, function ($a, $b) use ($properties) {

        foreach($properties as $property) {

          $objA = $a;
          $objB = $b;

          if(is_string($property)) {

            $property = explode('.', $property);
            foreach($property as $method) {

              if($objA == null || $objB == null) {
                break;
              }

              $getter = 'get' . $method;

              if(method_exists($objA, $method)) {
                $objA = $objA->$method();
              }
              elseif(method_exists($objA, $getter)) {
                $objA = $objA->$getter();
              }
              else {
                $objA = null;
              }

              if(method_exists($objB, $method)) {
                $objB = $objB->$method();
              }
              elseif(method_exists($objB, $getter)) {
                $objB = $objB->$getter();
              }
              else {
                $objB = null;
              }
            }
          }

          if($objA != null && $objB == null) {
            return -1;
          }
          elseif($objA == null && $objB != null) {
            return 1;
          }
          elseif($objA < $objB) {
            return -1;
          }
          elseif($objA > $objB) {
            return 1;
          }
        }

        return 0;
      });
    }

    return $objects;
  }
}

¿Pero como lo usamos? Pues básicamente en Twig, para obtener una lita de líneas de factura ordenadas por el nombre del producto, haríamos lo siguiente:

<ul>
{% for linea in factura.lineas | sortCollection([ 'producto.nombre' ]) %}
  <li>
    {{ linea.producto.nombre }}
  </li>
{% endfor %}
</ul>

Una extensión Twig, también puede definirse como servicio y por lo tanto, también podríamos ordenar una colección de objetos Doctrine directamente desde el controlador. Por ejemplo podríamos necesitar una lista de líneas de factura ordenadas primero por precio del producto asociado y segundo por el nombre (para productos del mismo precio). Sería como sigue:

$myExtension = $this->get('twig.extension.MyExtension');
$list = $myExtension->sortCollection($factura->getLineas(), array(
  'producto.precio',
  'producto.nombre'
));

No hay comentarios:

Publicar un comentario