martes, 18 de junio de 2013

Obteniendo resultados aleatorios

Después de varios meses ausente, recomenzamos con algo rápido, pero que en ocasiones puede ser de utilidad. Se trata del uso de una función de PostgreSQL que puede ser utilizada para obtener resultados aleatorios.

Supongamos que queremos mostrar 3 de los productos que tenemos en nuestra base de datos, pero que su elección sea totalmente aleatoria. Una solución muy sencilla de implementar en PostgreSQL es la siguiente:

SELECT * FROM productos ORDER BY random() LIMIT 3

Aunque esta consulta está bastante optimizada en PostgreSQL, la realidad es que para ejecutarla se requiere examinar o recorrer toda la tabla. Esto puede ser un problema para tablas con una enorme cantidad de registros, lo cual afectará el rendimiento de la aplicación que necesite utilizar este recurso.

Existen otras soluciones más eficientes, pero en su mayoría requieren la inclusión de nuevas columnas que son populadas utilizando valores aleatorios o secuenciales. Los incito a que creen sus propias soluciones  basadas en el uso de la función random(), que puedan mejorar la eficiencia de la solución propuestas para casos reales asociados a tablas con un número elevado de registros.

martes, 12 de febrero de 2013

Indice de Popularidad

Hola a todos, como bien dice el título de esta entrada, hoy estaremos hablando sobre la utilidad de definir un índice de popularidad y su posterior implementación y actualización.

En muchos casos necesitamos ordenar artículos en función de su relevancia o popularidad, conceptos que en tema de ventas, pueden definirse como el nivel de aceptación del producto. 
La aceptación normalmente depende de dos valores, total de ventas y evaluación posterior del producto. En nuestro caso haremos uso solamente del total de ventas, aunque un modelo más aceptado debería hacer uso de las críticas o evaluaciones de los clientes sobre el producto.

Las ventas normalmente varían en función del tiempo, y en muchos casos tienen un comportamiento periódico que se repite cada año, mostrándose valores similares en iguales meses o períodos, como puede pasar con productos relacionados con las estaciones (verano, invierno) o con fechas de celebraciones. Por tal motivo nuestro índice de popularidad no es valor estático, sino que es dependiente del tiempo y de la fecha actual, mes, estación o período.

Al igual que en las entradas anteriores utilizaremos el modelo de base de datos definido, aunque incluiremos un nuevo campo de tipo numeric a la tabla productos, al cual llamaremos popularidad.

Para su implementación comencemos primeramente por definir una consulta que nos devuelva el total de ventas para cada producto por año y mes. En postgres sería algo como:

SELECT EXTRACT(YEAR FROM o.fecha) AS anno, 
       EXTRACT(MONTH FROM o.fecha) AS mes, 
       a.producto, 
       SUM(a.cantidad) AS total
FROM ordenes o INNER JOIN articulos a ON (o.id_orden = a.orden)
GROUP BY anno, mes, producto

Este índice debe tener en cuenta los totales de los meses anteriores, pero está claro que mientras más alejado estén los totales en tiempo, menos peso deben tener en el índice. Para poder trabajar de manera cómoda con los años y meses, propongo utilizar un recurso o truco que he utilizado en varias ocasiones con resultados satisfactorios. Se trata de multiplicar el año por 12 y sumar el valor del mes, de manera que a cada dupla (año, mes) se le hace corresponder un número entero único que cumple con dos propiedades: mantiene el orden cronológico y cada mes difiere solo en uno respecto al mes anterior. Para lograr reducir el peso de los totales a medida que nos alejamos en tiempo se pueden utilizar varios recursos, particularmente utilizaré uno muy sencillo, dividir el total del mes entre la distancia de la dupla (año actual, mes actual) con la del (año, mes) asociado a ese total.

Primero veamos cómo obtener los valores numéricos asociados al mes y año.

SELECT EXTRACT(YEAR FROM current_date)*12 
     + EXTRACT(MONTH FROM current_date) AS base,
       t.anno*12 + t.mes AS indice,
       t.producto,
       t.total
FROM (
 SELECT EXTRACT(YEAR FROM o.fecha) AS anno, 
        EXTRACT(MONTH FROM o.fecha) AS mes, 
        a.producto, 
        SUM(a.cantidad) AS total
 FROM ordenes o INNER JOIN articulos a ON (o.id_orden = a.orden)
 GROUP BY anno, mes, producto
 ) t

Note que los resultados anteriores pueden obtenerse sin necesidad de utilizar una subconsulta, pero por motivos puramente didácticos la mantendré así.

En esa consulta obtenemos un valor base, asociado a la fecha actual, y un valor índice asociado a cada una de los años y meses obtenidos en la consulta de agrupamiento utilizada para obtener los totales por productos.
Ahora solo resta definir el índice de popularidad:

SELECT producto, SUM(total/(base-indice)) AS popularidad
FROM (
 SELECT EXTRACT(YEAR FROM current_date)*12 
      + EXTRACT(MONTH FROM current_date) AS base,
        t.anno*12 + t.mes AS indice,
        t.producto,
        t.total
 FROM (
  SELECT EXTRACT(YEAR FROM o.fecha) AS anno, 
         EXTRACT(MONTH FROM o.fecha) AS mes, 
         a.producto, 
         SUM(a.cantidad) AS total
  FROM ordenes o INNER JOIN articulos a ON (o.id_orden = a.orden)
  GROUP BY anno, mes, producto
  ) t
 ) b
WHERE indice>=(base - 60) AND indice<base GROUP BY producto

Nótese que se utilizan los valores de los 5 años anteriores (indice>=(base-60)) y no se utiliza el mes actual (indice<base), de hacerlo habría que modificar la fórmula en el SELECT, pues la división nos daría un error al intentar dividir por cero.

Básicamente se suman todos los totales de los últimos 5 años, pero en cada mes se reduce el peso del valor total, dividiendo por la diferencia entre el valor base y el valor indice. Otros modelos, que dependerán del nivel de dependencia del índice con el tiempo, pueden ser utilizados, como:

SUM(total/power(2,base-indice))

en el que se divide por la potencias de 2, de manera que el peso en cada mes será la mitad del peso del mes posterior.

Si queremos además dar un peso adicional a los totales correspondientes a meses del mismo periodo que el actual, pero de años anteriores, podemos utilizar una expresión similar a la siguiente, en la que se da un peso adicional a los totales de meses iguales a los 3 meses anteriores al actual, reduciendo su peso a medida que se alejan.

SUM(total*60/(base-indice)+total*CAST(mod(CAST((base-indice+9) AS INT),12) > 8 AS INT)*30/(base-indice))

Al igual que comentaba anteriormente, son solo propuestas que pueden ser modificadas y adecuadas al caso que se analice. En este caso se utiliza la función mod que devuelve el resto de la división de un número por otro, y se compara su resultado para saber si el resultado corresponde a uno de los meses que buscamos. Al convertir la comparación a un valor entero obtendremos 0 o 1, que permite adicionar el peso adicional si es uno de los meses correspondientes. El uso de 60 y 30, es para diferenciar los pesos, la decisión de estos valores depende, como he dicho anteriormente, del problema que se analice.

Finalmente actualizamos el campo popularidad de la tabla productos, utilizando una consulta de actualización.

UPDATE productos p SET popularidad = c.popularidad
FROM (
 SELECT producto, SUM(total*60/(base-indice) 
 + total*CAST( mod(CAST((base-indice+9) AS INT),12)>8 AS INT)*30/(base-indice)) AS popularidad
 FROM (
  SELECT EXTRACT(YEAR FROM current_date)*12 
       + EXTRACT(MONTH FROM current_date) AS base,
         t.anno*12 + t.mes AS indice,
         t.producto,
         t.total
  FROM (
   SELECT EXTRACT(YEAR FROM o.fecha) AS anno, 
          EXTRACT(MONTH FROM o.fecha) AS mes, 
          a.producto, 
          SUM(a.cantidad) AS total
   FROM ordenes o INNER JOIN articulos a ON (o.id_orden = a.orden)
   GROUP BY anno, mes, producto
   ) t
  ) b
 WHERE indice>=(base - 60) AND indice<base 
 GROUP BY producto
 ) c
WHERE p.id_producto = c.producto;

sábado, 29 de diciembre de 2012

Limitar los resultados solamente a un lado de la relación

Sucede que en ocasiones queremos obtener información de dos o más tablas, pero limitando la cantidad de resultados solamente a un lado de la relación, normalmente en la tabla maestra. 

Supongamos que adicionamos al modelo con el que hemos trabajado, la posibilidad de  almacenar varios teléfonos asociados a un mismo cliente, indicando además que tipo de teléfono es (fax, trabajo, móvil, casa), y detalles sobre días de la semana y horas en que se aconseja llamar a ese teléfono.
Luego nos solicitan obtener los datos de los 3 clientes que más han aportado a la compañía en el año 2012 conjuntamente con el valor total del importe, mostrando además todos sus teléfonos con las características adicionales.

Muestro a continuación las mejoras al modelo que hemos estado utilizando, donde se incluyen  dos nuevas tablas, una donde se especifican los tipos de teléfonos y otra donde se almacenarán propiamente los teléfonos asociados a cada cliente. Pierde sentido mantener el campo telefono de la tabla clientes.

Evidentemente para dar solución a este problema no podemos limitar la consulta a solo 3 registros, pues no obtendríamos el resultado correcto, ya que un cliente puede tener más de un teléfono.
Obtengamos primeramente los 3 clientes solicitados (problema similar abordado en otro post), y luego veamos cómo obtener sus teléfonos en la misma consulta. Está claro que para la presentación de esta información, sea cual sea la plataforma o aplicación se necesita luego  de un procesamiento previo, muchas veces incluidas implícitamente en los motores de plantillas de reportes, o de generación de páginas web dinámicas, a partir de un agrupamiento del conjunto de datos.

En postgres esta sería una solución a esa primera consulta:

SELECT c.id_cliente, c.nombre, c.direccion, c.ciudad, 
       SUM(a.precio_unitario*a.cantidad) AS importe_total
FROM clientes c INNER JOIN ordenes o ON (c.id_cliente=o.cliente)
     INNER JOIN articulos a ON (a.orden=o.id_orden)
WHERE EXTRACT(YEAR FROM o.fecha) = 2012
GROUP BY  c.id_cliente, c.nombre, c.direccion, c.ciudad
ORDER BY importe_total DESC
LIMIT 3

Si utilizáramos un enlace de la tabla clientes con la tabla telefonos_clientes para obtener los teléfonos, no tendríamos forma de conocer a cuántos registros debemos limitar el resultado, por lo que una posible solución es utilizar la consulta anterior como subconsulta a la que haremos un enlace con la tabla telefonos_clientes para obtener todos los telefonos de los clientes obtenidos en la subconsulta.

SELECT c.id_cliente, c.nombre, c.direccion, c.ciudad, c.importe_total, 
       t.numero as telefono, tt.nombre as tipo_telefono, t.detalles
FROM (
  SELECT c.id_cliente, c.nombre, c.direccion, c.ciudad, 
         SUM(a.precio_unitario*a.cantidad) AS importe_total
  FROM clientes c INNER JOIN ordenes o ON (c.id_cliente=o.cliente)
       INNER JOIN articulos a ON (a.orden=o.id_orden)
  WHERE EXTRACT(YEAR FROM o.fecha) = 2012
  GROUP BY  c.id_cliente, c.nombre, c.direccion, c.ciudad
  ORDER BY importe_total DESC
  LIMIT 3
) c 
LEFT JOIN telefonos_clientes t ON (c.id_cliente=t.cliente)
LEFT JOIN tipos_telefonos tt ON (t.tipo=tt.id_tipo)
ORDER BY c.importe_total DESC

Note que el enlace con telefonos_clientes se hace utilizando un LEFT JOIN, pues puede suceder que algunos clientes no tengan teléfonos asociados. Note además que para garantizar que el resultado final nos muestre los clientes ordenados por importe de mayor a menor, es obligatorio volver a ordenar por ese valor en la consulta exterior.

Esto es todo por ahora, en la próxima entrada estaremos hablando sobre cómo definir y obtener un índice de popularidad basado en resultados históricos.

miércoles, 12 de diciembre de 2012

Insertar y actualizar desde una subconsulta

Muchas veces necesitamos actualizar tablas con información procedente de otras tablas; en estos casos puede ser de mucha utilidad el uso de subconsultas anidadas en sentencias INSERT o UPDATE.

Utilizaremos nuevamente el Modelo de base de datos introducido en la primera entrada, y supondremos además que nos solicitan adicionar una nueva tabla que contendrá los datos computados de las ventas totales por sucursales, mensualmente. Presento está nueva tabla, la cual estará enlazada a la tabla sucursales.


Para popular esta nueva tabla utilizaremos una consulta de inserción, donde los valores serán obtenidos de una subconsulta que utiliza funciones de agregación.

INSERT INTO resumen_sucursales (sucursal,anno,mes,ventas)
SELECT o.sucursal, EXTRACT(YEAR FROM o.fecha) AS anno, 
       EXTRACT(MONTH FROM o.fecha) AS mes,
       SUM(a.cantidad*a.precio_unitario)
FROM ordenes o INNER JOIN articulos a ON (o.id_orden=a.orden)
GROUP BY sucursal, anno, mes

Es importante destacar que la subconsulta debe devolver tantas columnas como las contenidas en la cláusula INSERT, y los tipos de datos deben coincidir en el orden en que se presentan.

Supongamos que luego de haber populado la tabla resumen_sucursles, nos solicitan adicionar la columna ordenes, donde se almacenará la cantidad de ordenes realizadas por cada sucursal mensualmente, y queremos actualizar su valor sin tener que eliminar los registros existentes e insertar nuevamente.


En este caso debemos usar una consulta de actualización, utilizando una subconsulta para computar los valores y proveer el dato necesario para la actualización. Una posible solución es la siguiente:

UPDATE resumen_sucursales SET ordenes = (SELECT count(*)
 FROM ordenes
 WHERE EXTRACT(YEAR FROM fecha)=resumen_sucursales.anno 
   AND EXTRACT(MONTH FROM fecha)=resumen_sucursales.mes
   AND sucursal=resumen_sucursales.sucursal)

En este tipo de soluciones se precisa que la subconsulta devuelva solamente un registro, aunque se realizarán internamente tantas subconsultas como combinaciones de valores existan en la tabla resumen_sucursales para las columnas anno, mes y sucursal.
Existen otras variantes para escribir esta consulta de actualización, personalmente prefiero utilizar esta:

UPDATE resumen_sucursales SET ordenes=s.ordenes
FROM 
( SELECT sucursal, EXTRACT(YEAR FROM fecha) AS anno, 
         EXTRACT(MONTH FROM fecha) AS mes, COUNT(*) AS ordenes 
  FROM ordenes
  GROUP BY sucursal, anno, mes
) s 
WHERE s.anno = resumen_sucursales.anno 
  AND s.mes = resumen_sucursales.mes
  AND s.sucursal=resumen_sucursales.sucursal

Algunos gestores permiten utilizar un alias para la tabla que va a ser actualizada, pero otros no, así que tenga cuidado si va a hacer uso del mismo. Por otro lado, la subconsulta si requiere siempre de un alias pues el mismo debe ser utilizado en la cláusula WHERE que enlaza la tabla a actualizar con la subconsulta que contiene los datos que serán utilizados para actualizar. Note que aquí no hay restricciones en cuanto a la cantidad de registros que devuelve la subconsulta, pero si se quieren obtener los resultados correctos debe construirse correctamente la subconsulta y utilizar cuidadosamente la cláusula WHERE. Esta variante de consulta de actualización puede generalizarse para cualquier cantidad de campos, como veremos a continuación, en el que se actualizará además de la cantidad de órdenes, el total de ventas.

UPDATE resumen_sucursales SET ventas=s.ventas, ordenes=s.ordenes
FROM 
( SELECT sucursal, EXTRACT(YEAR FROM fecha) AS anno, 
         EXTRACT(MONTH FROM fecha) AS mes, 
         SUM(a.cantidad*a.precio_unitario) AS ventas, 
         COUNT(DISTINCT o.id_orden) AS ordenes 
  FROM ordenes o INNER JOIN articulos a ON (o.id_orden=a.orden)
  GROUP BY sucursal, anno, mes
) s 
WHERE s.anno = resumen_sucursales.anno 
  AND s.mes = resumen_sucursales.mes 
  AND s.sucursal=resumen_sucursales.sucursal

Note que en este caso debe utilizarse la cláusula DISTINCT al contar la cantidad de órdenes, porque se incluye la tabla articulos que puede contener varios registros por cada orden. En este caso también puede remplazarse la primea línea de la consulta por la siguiente:

UPDATE resumen_sucursales SET (ventas, ordenes)=(s.ventas, s.ordenes)

lo cual es válido también para cualquier cantidad de columnas.

Es todo por hoy, en el próxima entrada estaremos hablando sobre como Limitar los resultados solamente a un lado de la relación cuando se tienen dos o más tablas relacionadas.

sábado, 8 de diciembre de 2012

Agrupamiento y búsqueda

Sucede a menudo que después de utilizar alguna función de agregación, como MAX y MIN, deseamos saber que valores de otros campos tienen asociados esos valores máximos o mínimos. Pongamos un ejemplo utilizando el Modelo de base de datos introducido en la primera entrada.

Si queremos hallar el precio máximo entre todos los productos, basta ejecutar la siguiente consulta:

SELECT MAX(precio)
FROM productos

Si quisiéramos saber cuál es el producto que tiene el precio máximo, una posible solución pudiera ser obtener el primer producto ordenando los resultados descendentemente por precio, en PostgreSQL sería:

SELECT id_producto, nombre, precio
FROM productos
ORDER BY precio DESC
LIMIT 1

Pero esta solución tiene un problema: si existe más de un producto con el máximo precio, solo obtendríamos uno de ellos. Para poder obtenerlos todos tendríamos que buscar todos los productos cuyo precio coincide con el precio máximo:

SELECT id_producto, nombre, precio
FROM productos
WHERE precio = (SELECT MAX(precio) FROM productos)

Analicemos otros ejemplos más complejos.

Se nos solicita ahora devolver los clientes que han ordenado la mayor cantidad total (monetariamente) en el año 2011, y digo clientes, porque puede darse el caso de que existan al menos dos clientes con igual cantidad total.

La siguiente consulta me devolvería la máxima cantidad total que ha sido ordenada por un mismo cliente en el año 2011:

SELECT MAX(importe_total) as importe_total_maximo
FROM ( 
 SELECT o.cliente, SUM(a.precio_unitario*a.cantidad) AS importe_total
 FROM ordenes o INNER JOIN articulos a ON (a.orden=o.id_orden)
 WHERE EXTRACT(YEAR FROM o.fecha) = 2011
 GROUP BY o.cliente
) v

Notemos aquí la necesidad de utilizar una subconsulta por el hecho de que no se pueden anidar llamadas a funciones de agregación (al menos en PostgreSQL y la gran mayoría de los gestores de base de datos), y se necesita primero computar la suma para después buscar el máximo.
Aunque en lugar de esa solución también podemos emplear esta:

SELECT SUM(a.precio_unitario*a.cantidad) AS importe_total
FROM ordenes o INNER JOIN articulos a ON (a.orden=o.id_orden)
WHERE EXTRACT(YEAR FROM o.fecha) = 2011
GROUP BY o.cliente
ORDER BY importe_total DESC
LIMIT 1

Debemos entonces, por analogía con el problema anterior, buscar todos los clientes cuyo importe total ordenado en el año 2011 coincide con el máximo valor.

SELECT c.id_cliente, c.nombre,
       SUM(a.precio_unitario*a.cantidad) AS importe_total
FROM clientes c INNER JOIN ordenes o ON (c.id_cliente=o.cliente)
     INNER JOIN articulos a ON (a.orden=o.id_orden)
WHERE EXTRACT(YEAR FROM o.fecha) = 2011
GROUP BY  c.id_cliente, c.nombre
HAVING SUM(a.precio_unitario*a.cantidad) = 
(
 SELECT MAX(importe_total) as importe_total_maximo
 FROM ( 
  SELECT o.cliente, SUM(a.precio_unitario*a.cantidad) AS importe_total
  FROM ordenes o INNER JOIN articulos a ON (a.orden=o.id_orden)
  WHERE EXTRACT(YEAR FROM o.fecha) = 2011
  GROUP BY o.cliente
 ) v 
)

Nótese la necesidad de utilizar HAVING, pues la comparación con el valor máximo debe hacerse después de haber computado la suma. En la subconsulta que devuelve el máximo puede utilizarse cualquiera de las dos variantes mostradas anteriormente, los resultados prácticos no dieron diferencias significativas en cuanto a la velocidad de ejecución de las mismas. En la consulta externa incorporamos además la tabla clientes para poder obtener el nombre de los clientes, y evidentemente debemos incluir ese campo en la cláusula GROUP BY. Utilicé además el campo id_cliente de la tabla clientes, pero de igual manera podía haber utilizado el campo cliente de la tabla ordenes.

Si se deseara solamente el cliente que primero alcanzó la máxima cantidad, entonces  (suponiendo que es casi improbable que dos clientes alcanzaron la máxima cantidad el mismo día) no necesitaríamos complicarnos tanto, y bastaría la siguiente consulta:

SELECT c.id_cliente, c.nombre, MAX(o.fecha) AS ultima_fecha, 
       SUM(a.precio_unitario*a.cantidad) AS importe_total
FROM clientes c INNER JOIN ordenes o ON (c.id_cliente=o.cliente)
     INNER JOIN articulos a ON (a.orden=o.id_orden)
WHERE EXTRACT(YEAR FROM o.fecha) = 2011
GROUP BY  c.id_cliente, c.nombre
ORDER BY importe_total DESC, ultima_fecha ASC
LIMIT 1

Aún así, no crean en probabilidades nulas, y aunque se compliquen, utilicen consultas que no den lugar a errores por suposiciones. En este caso una solución correcta sería (en PostgreSQL):

SELECT c.id_cliente, c.nombre, MAX(o.fecha) AS ultima_fecha, 
       SUM(a.precio_unitario*a.cantidad) AS importe_total
FROM clientes c INNER JOIN ordenes o ON (c.id_cliente=o.cliente)
     INNER JOIN articulos a ON (a.orden=o.id_orden)
WHERE EXTRACT(YEAR FROM o.fecha) = 2011
GROUP BY  c.id_cliente, c.nombre
HAVING (SUM(a.precio_unitario*a.cantidad), MAX(o.fecha)) = 
(
 SELECT SUM(a.precio_unitario*a.cantidad) AS importe_total, 
        MAX(o.fecha) AS ultima_fecha
 FROM ordenes o  INNER JOIN articulos a ON (a.orden=o.id_orden)
 WHERE EXTRACT(YEAR FROM o.fecha) = 2011
 GROUP BY o.cliente
 ORDER BY importe_total DESC, ultima_fecha ASC
 LIMIT 1
)

En la subconsulta se obtiene el importe total entre todos los clientes, y la fecha mínima en la que se alcanzó, debido a que se ordena el importe total descendentemente y el máximo de fecha ascendentemente, agrupados por clientes, y tomando solamente el primer resultado. Luego se buscan los clientes cuyo importe total y última fecha de orden coinciden con esos valores, a partir de una comparación de ambos valores en la cláusula HAVING con los resultados obtenidos en la subconsulta.

En la próxima entrada estaremos hablando sobre como insertar y actualizar desde una subconsulta.

miércoles, 5 de diciembre de 2012

COALESCE al rescate

En muchas ocasiones debemos tratar con valores NULL, los cuales necesitamos remplazar por otro valor. En este caso es útil la función COALESCE, la cual retorna el primero de sus argumentos que no es NULL. Evidentemente solo retornará NULL cuando todos sus argumentos son iguales a NULL. Como comentaba, se utiliza normalmente para sustituir los valores NULL por un valor por defecto.

A continuación planteo un problema en el que será muy útil su uso, y en el que además de utilizarla para reemplazar los posibles valores NULL de un campo por un valor predeterminado, se utilizará para determinar el valor de un campo obtenido como resultado de combinar dos conjuntos de datos usando FULL OUTER JOIN.

En el modelo de base de datos descrito en el post anterior, se nos solicita un informe donde mostremos para el mes 11 del año 2012, un resumen de todos los productos, con el valor planificado y el valor real de ventas. Solo se pide mostrar aquellos productos que han sido planificados y/o que han sido vendidos en ese mes, teniendo en cuenta que algunos productos pueden haber sido planificados sin haber tenido ventas, o pueden haber tenido ventas si haber sido planificados. Se conoce además que en la tabla plan_ventas los campos anno, mes y producto presentan una restricción de unicidad.

Presento una posible solución para PostgreSQL, aunque para otros gestores de base de datos la solución es similar, solamente intercambiando el uso de la función EXTRACT para extraer el año y el mes de la fecha de venta por sus equivalentes, aunque también puede utilizarse un filtro de comparación de fechas.

SELECT COALESCE(v.producto,pv.producto) AS producto, 
       COALESCE(v.ventas,0) AS ventas, 
       COALESCE(pv.plan,0) AS plan
FROM
(
  SELECT a.producto, SUM(a.cantidad) as ventas
  FROM articulos a INNER JOIN ordenes o ON (a.orden=o.id_orden)
  WHERE EXTRACT(YEAR FROM o.fecha)=2012 
    AND EXTRACT(MONTH FROM o.fecha)=11
  GROUP BY a.producto
) v
FULL OUTER JOIN plan_ventas pv 
  ON (pv.producto=v.producto AND pv.anno=2012 AND pv.mes=11)
ORDER BY ventas DESC

Notemos varios puntos
  • Se utiliza una subconsulta para obtener los resultados parciales asociados a la venta. Existen otras soluciones en las que no se precisa del uso de subconsultas, pero las pruebas arrojaron que eran menos eficientes, pues el agrupamiento se realiza después de haber realizado la combinación entre todas las tablas.
  • Se utiliza FULL OUTER JOIN para combinar ambos conjuntos de datos, pues se requieren todos los registros de ambas tablas aun cuando no haya coincidencia de productos.
  • La restricción de Año y Mes de la tabla plan_ventas se coloca como parte de la condición en la que se basa la combinación con la subconsulta y no en la cláusula WHERE. Al colocarla en la cláusula ON estamos especificando cuales filas de la tabla plan_ventas serán tomadas en consideración para combinar con la subconsulta, pues los predicados de la cláusula ON se aplican a la tabla antes de la combinación. Si colocamos esta restricción en la cláusula WHERE estamos restringiendo las filas al resultado de la combinación, y evidentemente aquellas filas de la subconsulta (ventas) que contengan productos  que no estén en plan_ventas no serán mostrados, pues el valor del campo anno y mes serán NULL; una posibilidad sería utilizar en la cláusula WHERE la siguiente restricción:  
    WHERE (pv.anno=2012 OR pv.anno IS NULL)
      AND (pv.mes=11 OR pv.mes IS NULL)
    
    pero es menos intuitiva. Otra variante sería utilizar una subconsulta para seleccionar las filas que nos interesan de la tabla
    plan_ventas, antes de combinarla con la subconsulta de ventas.
  • Se utiliza la función COALESCE en tres ocasiones, la primera para determinar el código del producto en cada fila resultante, pues existirán casos en que uno de los dos valores indistintamente puede ser NULL. En los otros dos casos se utiliza para asignar valor 0 cuando el valor de ventas o del plan sean NULL.
Esta consulta nos devuelve solamente los identificadores de los productos, si quisiéramos los nombres de los productos deberíamos entonces combinar ese resultado con la tabla productos, de la siguiente manera:

SELECT p.id_producto, p.nombre AS nombre_producto, 
      COALESCE(v.ventas,0) AS ventas, 
      COALESCE(pv.plan,0) AS plan
FROM
(
  SELECT a.producto, SUM(a.cantidad) as ventas
  FROM articulos a INNER JOIN ordenes o ON (a.orden=o.id_orden)
  WHERE EXTRACT(YEAR FROM o.fecha)=2012 
    AND EXTRACT(MONTH FROM o.fecha)=11
  GROUP BY a.producto
) v
FULL OUTER JOIN plan_ventas pv 
  ON (pv.producto=v.producto AND pv.anno=2012 AND pv.mes=11)
INNER JOIN productos p 
  ON (COALESCE(v.producto, pv.producto)=p.id_producto)
ORDER BY ventas DESC

Nótese que se utiliza la función COALESCE como parte de la condición en la que se basa la combinación de productos con el resultado previo, aunque en su defecto también pudiera utilizarse:

ON (v.producto=p.id_producto OR pv.producto=p.id_producto)

Evidentemente hay varias soluciones para este problema, intenté mostrar la más intuitiva y una de las más eficientes.

En la próxima entrada estaremos hablando sobre: Agrupamientoy búsqueda, donde veremos casos prácticos en los que se necesita localizar las filas asociadas a campos con valores máximos o mínimos.