Cada vez que repites código Dios mata un gatito (parte 1)

Definiciones
lunes, 21 de enero de 2013

Cada vez que repites código, Dios mata un gatito

Una crítica al lenguaje SQL

La semana pasada hablaba de la importancia de la filosofía DRY en los proyectos de datawarehousing y mencionaba el SQL como ejemplo de lenguaje excesivamente reiterativo. Paradójicamente, el lenguaje declarativo por excelencia (SQL) contiene una serie de defectos WET impensables en lenguajes OO (C#, Java, Phyton, etc.).

Tras el galimatías de siglas del primer párrafo, la audiencia de este artículo habrá quedada mermada por lo menos a la mitad, por lo que si has leído hasta aquí ya te habrás dado cuenta que este artículo va dirigido a mis lectores más técnicos. Si y solo si estás en este grupo, puedes seguir leyendo. Avisado estás.

Como sabrás, el desarrollo ETL suele representar el 70% del costo de los proyectos de Business Intelligence , y la excesiva verbosidad del lenguaje SQL es la responsable de buena parte de esa complejidad. Merece la pena, por lo tanto, analizar el problema de este lenguaje. Empezaré comentando las cosas más triviales e iré introduciendo el resto de “defectos WET” que provocan que el desarrollo y mantenimiento del código SQL sea penoso y hasta en ocasiones inasumible (¿De qué si no iban a existir las herramientas ETL?).

Ejemplos

Pongamos el caso de la base de datos de ejemplo que incluye SQL Server (AdventureWorksDW). Este es el modelo de datos de una de sus estrellas:

Modelo de datos de una estrella de ventas del data warehouse de ejemplo de Adventure Works

Veamos cómo serían algunas consultas contra este modelo.

Por ejemplo, si me piden “el detalle de las bicicletas vendidas en enero de 2008, ordenadas en función de su precio” tengo que escribir esta consulta SQL:

                -- Ejemplo 1

SELECT
  DimProduct.EnglishProductName AS Producto,
  sum(sales.OrderQuantity) AS Unidades,
  sum(sales.SalesAmount) AS Importe,
  sum(sales.SalesAmount)/sum(sales.OrderQuantity) AS [Precio Medio]
FROM FactResellerSales sales
INNER JOIN DimProduct ON (sales.ProductKey=DimProduct.ProductKey)
INNER JOIN DimProductSubCategory ON (DimProduct.ProductSubcategoryKey=DimProductSubCategory.ProductSubcategoryKey)
INNER JOIN DimProductCategory ON (DimProductSubCategory.ProductCategoryKey=DimProductCategory.ProductCategoryKey)
INNER JOIN DimDate ON (sales.OrderDateKey=DimDate.DateKey)
WHERE
  DimProductCategory.SpanishProductCategoryName='Bicicleta'
  AND DimDate.SpanishMonthName='Enero'
  AND DimDate.CalendarYear=2008
GROUP BY DimProduct.EnglishProductName
ORDER BY sum(sales.SalesAmount)/sum(sales.OrderQuantity) DESC



              

A simple vista, ya se detectan algunas reiteraciones “innecesarias”:

  • El GROUP BY . ¿Por qué hemos de poner siempre el GROUP BY ? En un porcentaje altísimo de las ocasiones, las columnas GROUP BY son fácilmente deducibles de las columnas de la cláusula SELECT (…s e ha de agrupar por todas las columnas salvo aquellas que son funciones de agregación tipo sum, max , min y avg ).
  • Las fórmulas. En el ejemplo anterior, la fórmula sum(sales.OrderQuantity) se repite hasta 3 veces (en la columna “Unidades”, en la columna “Precio”, y en la cláusula ORDER BY ). También se repiten “innecesariamente” las fórmulas de importe y precios.

Sé que las cosas anteriores pueden parecer triviales pero, como dije el otro día, hemos de cuestionar cualquier incumplimiento de la máxima DRY. En casos reales, el GROUP BY puede tener muchas columnas, y las fórmulas pueden complicarse con CASE y otros artificios diabólicos. Además, estos defectos del lenguaje se acumulan: Imagínate una fórmula complicada en el SELECT, que se reutiliza en otra columna del SELECT, y que se agrupa en el GROUP BY, se filtra en el WHERE, y se ordena en el ORDER BY…)…

Si no te he convencido, aún, vuelve a mirar la imagen que encabeza este artículo y, por favor, piensa en los gatitos. Pero por lo que más quieras, ¡No repitas código!

Además de estos 2 defectos WET, existe otro menos evidente. Veamos otros ejemplos para descubrirlo:

Si me piden “las bicicletas vendidas en Alemania durante el 2008” haré esta consulta:

                -- Ejemplo 2

SELECT
  DimProduct.EnglishProductName AS EnglishProductName,
  sum(sales.OrderQuantity) AS Unidades
FROM FactResellerSales sales
INNER JOIN DimReseller ON (sales.ResellerKey=DimReseller.ResellerKey)
INNER JOIN DimGeography ON (DimReseller.GeographyKey=DimGeography.GeographyKey)
INNER JOIN DimProduct ON (sales.ProductKey=DimProduct.ProductKey)
INNER JOIN DimProductSubCategory ON (DimProduct.ProductSubcategoryKey=DimProductSubCategory.ProductSubcategoryKey)
INNER JOIN DimProductCategory ON (DimProductSubCategory.ProductCategoryKey=DimProductCategory.ProductCategoryKey)
INNER JOIN DimDate ON (sales.OrderDateKey=DimDate.DateKey)
WHERE
  DimGeography.SpanishCountryRegionName='Alemania'
  AND DimProductCategory.SpanishProductCategoryName='Bicicleta'
  AND DimDate.CalendarYear=2008
GROUP BY DimProduct.EnglishProductName


              

O si me piden “el total de unidades vendidas en 2008 por países” haré esta consulta:

                --Ejemplo 3

SELECT
  DimGeography.SpanishCountryRegionName AS SpanishCountryRegionName,
  sum(sales.OrderQuantity) AS Unidades
FROM FactResellerSales sales
INNER JOIN DimReseller ON (sales.ResellerKey=DimReseller.ResellerKey)
INNER JOIN DimGeography ON (DimReseller.GeographyKey=DimGeography.GeographyKey)
INNER JOIN DimDate ON (sales.OrderDateKey=DimDate.DateKey)
WHERE DimDate.CalendarYear=2008
GROUP BY DimGeography.SpanishCountryRegionName


              

¿Ves ya lo que estamos repitiendo consulta tras consulta? Efectivamente:

  • Los JOINS . Desde que he puesto la imagen del modelo de datos, todos sabíamos ya como relacionar las tablas. Prácticamente siempre las relaciones es algo propio del modelo de datos, es estático, y es independiente de la consulta que quiera hacer el usuario. Puedo querer una información u otra, pero los JOINS son los que son, ¿Por qué los he de reescribir en cada ocasión? Además, si por cualquier motivo cambia el modelo de datos, deberemos retocar, reescribir, y reverificar cada una de las dos consultas. ¿Aún no ves el daño que hacen estos defectos WET en el código SQL que escribimos?

Operaciones entre filas con SQL

Para finalizar por hoy comentaré el primero de los problemas no triviales. Se dice que con SQL es muy sencillo hacer operaciones entre las columnas (como calcular el precio medio, por ejemplo), pero que es muy complicado hacer operaciones entre filas (como comparar las ventas respecto al año anterior). En realidad, no es nada complicado, simplemente es un coñazo poco entretenido.

Por ejemplo, si me piden “el detalle de las bicicletas vendidas en enero de 2008 y en enero del 2007”, haré una consulta similar a ésta:

                -- Ejemplo 4

WITH
sales1 AS (
  SELECT
    DimProduct.EnglishProductName AS EnglishProductName,
    sum(sales.OrderQuantity) AS auxcol_2_
  FROM FactResellerSales sales
  INNER JOIN DimProduct ON (sales.ProductKey=DimProduct.ProductKey)
  INNER JOIN DimProductSubCategory ON (DimProduct.ProductSubcategoryKey=DimProductSubCategory.ProductSubcategoryKey)
  INNER JOIN DimProductCategory ON (DimProductSubCategory.ProductCategoryKey=DimProductCategory.ProductCategoryKey)
  INNER JOIN DimDate ON (sales.OrderDateKey=DimDate.DateKey)
  WHERE
    DimDate.CalendarYear=2007
    AND DimProductCategory.SpanishProductCategoryName='Bicicleta'
    AND DimDate.SpanishMonthName='Enero'
  GROUP BY DimProduct.EnglishProductName
),
sales2 AS (
  SELECT
    DimProduct.EnglishProductName AS EnglishProductName,
    sum(sales.OrderQuantity) AS auxcol_3_
  FROM FactResellerSales sales
  INNER JOIN DimProduct ON (sales.ProductKey=DimProduct.ProductKey)
  INNER JOIN DimProductSubCategory ON (DimProduct.ProductSubcategoryKey=DimProductSubCategory.ProductSubcategoryKey)
  INNER JOIN DimProductCategory ON (DimProductSubCategory.ProductCategoryKey=DimProductCategory.ProductCategoryKey)
  INNER JOIN DimDate ON (sales.OrderDateKey=DimDate.DateKey)
  WHERE
    DimDate.CalendarYear=2008
    AND DimProductCategory.SpanishProductCategoryName='Bicicleta'
    AND DimDate.SpanishMonthName='Enero'
  GROUP BY DimProduct.EnglishProductName
),
COMUN AS (
  SELECT DISTINCT EnglishProductName FROM sales1
  UNION SELECT DISTINCT EnglishProductName FROM sales2
)
SELECT
  COMUN.EnglishProductName,
  sales1.auxcol_2_ AS [Ventas Enero 2007],
  sales2.auxcol_3_ AS [Ventas Enero 2008]
FROM COMUN
LEFT JOIN sales1 ON COMUN.EnglishProductName=sales1.EnglishProductName
LEFT JOIN sales2 ON COMUN.EnglishProductName=sales2.EnglishProductName





              

A pesar de la verbosidad del código anterior, la consulta se ejecuta perfectamente y escala linealmente (consultar 2 meses de este modo cuesta el doble que consultar un solo mes, lógicamente). El único problema del código es que repite todo innumerables veces: Los GROUP BYs, los WHEREs, los JOINs … Lo cual confirma que el ANSI SQL es tediosamente verboso y afecta gravemente a la mantenibilidad del código generado…

QED.

ACTUALIZACIÓN: Os dejo a continuación el enlace a todos los artículos de esta serie con final sorprendente.