viernes, 12 de junio de 2009

NUTS-QL (Negate/Unite/Test Standard Query Language) [DRAFT].

cucú


Nota para lectores del feed: el código SQL es más legible si lo ven formateado en la entrada original.


¿Qué es NUTS-QL? Para hacer honor a su nombre, comencemos por la negativa: NUTS-QL no es un dialecto SQL. NUTS-QL es una técnica de creación y mantenimiento de consultas SQL, un conjunto de prácticas y principios, y por lo tanto puede aplicarse en conjunción con cualquiera de los dialectos de SQL conocidos.

¿Cuáles son sus orígenes? NUTS-QL es una etiqueta bajo la cual se agrupa un conjunto coherente de técnicas que -por separado- son conocidas y utilizadas por innumerables desarrolladores a lo largo y ancho del globo. Esta compilación y su organización es obra, humildemente, de mi autoría.

¿Cuáles son sus ventajas? La principal ventaja de NUTS-QL es que va más allá de asegurar un código SQL legible: hace que la legibilidad sea irrelevante.

Vamos directamente a un ejemplo. El requerimiento será desarrollar el “Reporte de movimientos diarios”.

El primer paso es escribir lo primero que se nos venga a la cabeza, probar que compile y mandarlo a pruebas:

SELECT C.ID, C.DESCRIPCION, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
ORDER BY C.FECHA

¡Y eso es todo! No hace falta documentar nada. En el improbable caso de que luego de un par de días, semanas o meses, a alguien le toque modificar la consulta, aplicará los principios de NUTS-QL.

Digamos que ahora se necesita que “en los movimientos de emisión de cheques (MOVIMIENTOS_CABECERA.TIPO=1) se presente el número de cheque emitido”.

Lo primero que tenemos que hacer es implementar el requerimiento con una consulta aparte que satisfaga sólo el caso mencionado. Si bien puede utilizarse la consulta anterior como base, usualmente es más fácil no preocuparse por lo que ya está hecho, que probablemente no sirva para nada. Otra vez escribimos lo primero que se nos viene a la cabeza:

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1
ORDER BY C.FECHA

Ahora hay que integrar las dos consultas. Son tres fases. La primera es la de negación (Negative), en la que excluimos de las cláusulas anteriores los registros que incorporamos en la nueva. Esto es fácil porque podemos tomar el WHERE de la nueva y agregarlo en la primera precedido por los operadores AND NOT (ver que la línea 5 del ejemplo siguiente es la negación de la línea 5 del ejemplo anterior). La primera consulta nos queda:

SELECT C.ID, C.DESCRIPCION, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
ORDER BY C.FECHA

La segunda es la de unión (Unite), en donde unimos las dos consultas con la cláusula UNION:

SELECT C.ID, C.DESCRIPCION, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
ORDER BY C.FECHA

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1
ORDER BY C.FECHA

La tercera es la de prueba (Test), que consiste en ejecutar y corregir hasta que funcione. En este caso hay dos problemas: el ORDER BY está repetido (va uno sólo al final) y la cantidad de campos en la sección SELECT no coinciden entre las dos partes (rellenamos con nulos donde sea necesario). Necesitaremos al menos dos intentos. El resultado será:

SELECT C.ID, C.DESCRIPCION, NULL AS NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ 
	ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1

ORDER BY C.FECHA

Es muy, muy improbable que surja una tercera modificación o error, pero agreguemos una a modo de ejemplo. Supongamos que nos enteramos de que “los importes en moneda extranjera (registros en los que MOVIMIENTOS_DETALLE.IDMONEDA <> NULL) no se están convirtiendo a la moneda corriente de acuerdo al tipo de cambio indicado en MOVIMIENTOS_DETALLE.COTIZACION”.

Apliquemos NUTS-QL. La consulta para los registros en moneda extranjera será:

SELECT C.ID, C.DESCRIPCION, 
	D.DEBE * D.COTIZACION, 
	D.HABER * D.COTIZACION
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL
ORDER BY C.FECHA

Ahora, en la etapa de negación, debemos modificar la sección WHERE de las consultas anteriores negando la condición WHERE de la nueva sección (ok, son 2, pero es sólo copiar y pegar).

La primera queda:

WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)

y la segunda:

WHERE C.FECHA = @FECHA AND C.TIPO = 1
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)

Luego, en la etapa de unión nos queda:

SELECT C.ID, C.DESCRIPCION, NULL AS NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ 
	ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)
ORDER BY C.FECHA

UNION 

SELECT C.ID, C.DESCRIPCION, 
	D.DEBE * D.COTIZACION, 
	D.HABER * D.COTIZACION
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL
ORDER BY C.FECHA

Ya en la etapa de test, nos damos cuenta de que (otra vez) el ORDER BY está repetido y de que tenemos que rellenar el SELECT de la nueva consulta con campos nulos para que coincida con el de las anteriores. El resultado final será:

SELECT C.ID, C.DESCRIPCION, NULL AS NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ 
	ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)

UNION 

SELECT C.ID, C.DESCRIPCION, NULL, 
	D.DEBE * D.COTIZACION, 
	D.HABER * D.COTIZACION
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL

ORDER BY C.FECHA

Y listo, nuevamente a pruebas y producción.

Los puristas encontrarán que en aquellos registros que corresponden a cheques nominados en moneda extranjera no se está mostrando el número. En el cuasi-imposible caso de que esto se detecte nos llegará el requerimiento correspondiente. Normalmente deberíamos entender toda la consulta y buscar el error. Gracias a NUTS-QL simplemente hacemos lo de siempre.

Para abreviar les dejo sólo el resultado final de aplicar la técnica a este último caso (es un buen ejercicio que el lector lo haga por su cuenta y compare los resultados):

SELECT C.ID, C.DESCRIPCION, NULL AS NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1)
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1 
		AND NOT D.IDMONEDA IS NULL)

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, D.DEBE, D.HABER
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ 
	ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1
	AND NOT (C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL)
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1 
		AND NOT D.IDMONEDA IS NULL)

UNION 

SELECT C.ID, C.DESCRIPCION, NULL, 
	D.DEBE * D.COTIZACION, 
	D.HABER * D.COTIZACION
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
WHERE C.FECHA = @FECHA AND NOT D.IDMONEDA IS NULL
	AND NOT (C.FECHA = @FECHA AND C.TIPO = 1 
		AND NOT D.IDMONEDA IS NULL)

UNION

SELECT C.ID, C.DESCRIPCION, CHQ.NUMERO, 
	D.DEBE * D.COTIZACION, 
	D.HABER * D.COTIZACION
FROM MOVIMIENTOS_CABECERA AS C 
INNER JOIN MOVIMIENTOS_DETALLE AS D ON C.ID=D.ID
INNER JOIN CHEQUES AS CHQ 
	ON CHQ.MOVIMIENTO_EMISION_ID = C.ID
WHERE C.FECHA = @FECHA AND C.TIPO = 1 
	AND NOT D.IDMONEDA IS NULL

ORDER BY C.FECHA

Comentarios: se le achaca a este método producir sentencias cada vez más ilegibles e ineficientes. Pero hemos visto que no es necesario leerlas, por lo que el primer punto es irrelevante. En cuanto al segundo… bueno, eso es un problema de hardware ¿no?

Lo mejor de todo es que lograr la adopción de NUTS-QL es fácil: una vez que un desarrollador comienza los demás están obligados a seguirlo, basta con respetar el punto de no documentación de requerimientos (si éstos estuviesen documentados, un programador inexperto podría estar tentado a reescribir toda la sentencia). Así, es un camino de ida.


Nota para lectores del feed: el código SQL es más legible si lo ven formateado en la entrada original.

4 comentarios:

Anónimo dijo...

Ah, vale. Ya lo entendí. Tu ironía/crítica nace de algo obvio: no se está utilizando la cláusula UNION ALL. ¡Vaya despilfarro de rendimiento!

XD

P.D. Deberías patentar esta metodología, quizá tuviese más éxito del que imaginas... :)

AcP dijo...

Me olvidé del UNION ALL!!!! Qué pecado, es una buena optimización. Pero voy a ser fiel al principio básico del NUTS-QL y no corregir nada (lo usaré de aquí en más).

Patentar? Nah, lo mío es un aporte desinteresado a la humanidad.

Gracias por el comentario, nos leemos!

Anónimo dijo...

No hay nada más horrible que nombres de tablas y campos en castellano... nada dije!

AcP dijo...

Pero metiendo código en inglés te expones a que después venga alguien que sabe y te deje en ridículo.

Aunque nunca encontré un buen reemplazo para "Get" (como en GetCostumers). "Obtener" es muy largo (ObtenerClientes). Una vez vi un sistema en que le ponían "Dame", quedaba gracioso (DameClientes), no sé, poco serio. "Traer" (TraerClientes) no me convence... es un problema.

Algunos menos puristas lo resuelven fácil: "GetClientes" y listo.

¿A qué viene esto? Ah, bueh, a nada.