En DOSCLIC, frecuentemente nos enfrentamos a desafíos que, aunque parezcan sencillos, requieren una comprensión profunda de SQL Server y T-SQL. Recientemente, me encontré depurando una serie de consultas y procedimientos almacenados que involucraban desde el redondeo preciso de promedios hasta la gestión de fechas y la lógica de filtros condicionales. Este artículo documenta el proceso, los errores comunes y las soluciones que implementé para asegurar la exactitud y eficiencia de nuestras operaciones de base de datos.
1. El Desafío del Redondeo de Promedios al Entero Más Cercano
Mi primer objetivo era calcular el promedio de una columna de notas (NotaParcial1) y redondearlo al entero más cercano, sin mostrar decimales. Inicialmente, probé con las siguientes aproximaciones:
ROUND(AVG(mtme.NotaParcial1), 2),
CAST(AVG(mtme.NotaParcial1) AS decimal(10,2))
El resultado que obtenía era el siguiente:
(No column name) (No column name)
75.250000 75.25
71.500000 71.50
70.500000 70.50
77.750000 77.75
76.750000 76.75
Ambas opciones me devolvían valores con decimales, lo cual no era lo que buscaba. Necesitaba un redondeo al entero más cercano.
1.1. Primer Intento: ROUND con cero decimales
La lógica me indicaba que ROUND(..., 0) debería ser la solución:
ROUND(AVG(mtme.NotaParcial1), 0)
Esta función calcula el promedio y lo redondea al entero más cercano. Sin embargo, para mi sorpresa, el resultado seguía mostrando decimales, aunque fueran ceros:
promedio_redondeo
75.000000
72.000000
71.000000
78.000000
77.000000
ROUND(AVG(mtme.NotaParcial1), 0) redondea el valor correctamente al entero más cercano, el tipo de dato resultante (heredado de AVG() y la columna original) puede seguir siendo decimal(x,y), mostrando ceros en la parte decimal. Esto no es un error de redondeo, sino de representación del tipo de dato.
1.2. La Solución: Conversión Explícita del Tipo de Dato
Para eliminar definitivamente la parte decimal de la representación, era necesario realizar una conversión explícita del tipo de dato. Tenía dos opciones principales:
Opción A: Convertir a INT
Si el objetivo es un número entero puro, la conversión a INT es la más directa:
CAST(ROUND(AVG(mtme.NotaParcial1), 0) AS INT) AS promedio_redondeo
Esto produce el resultado deseado:
promedio_redondeo
75
72
71
78
77
Opción B: Convertir a DECIMAL(10,0)
Si por alguna razón se necesita mantener el tipo de dato como DECIMAL pero sin parte decimal, se puede especificar la precisión a cero:
CAST(ROUND(AVG(mtme.NotaParcial1), 0) AS DECIMAL(10,0)) AS promedio_redondeo
Ambas opciones resuelven el problema de la visualización de decimales.
1.3. Verificación del Tipo de Dato con SQL_VARIANT_PROPERTY
Para confirmar que el tipo de dato final era realmente un entero, utilicé la función SQL_VARIANT_PROPERTY:
SELECT
SQL_VARIANT_PROPERTY(CAST(ROUND(AVG(mtme.NotaParcial1), 0) AS INT), 'BaseType') AS TipoDato
FROM [Tabla de Materias]
El resultado fue concluyente:
TipoDato
int
int
int
int
int
SQL_VARIANT_PROPERTY(expresión, 'BaseType') es invaluable para inspeccionar el tipo de dato real de cualquier expresión en SQL Server, ayudando a diagnosticar problemas de conversión o representación.
2. Optimizando Lógica Condicional con CASE WHEN
Otro punto de mejora surgió en una cláusula CASE WHEN anidada que buscaba filtrar datos bajo múltiples condiciones:
CASE
WHEN p.subject = 130 THEN
CASE
WHEN mp.subject IN (290,291,292,293) THEN mtme.NotaParcial1
END
END AS mo
Intenté simplificarla eliminando el CASE anidado, pero cometí un error al no incluir un operador lógico:
CASE
WHEN p.subject = 130 mp.subject IN (290,291,292,293) THEN mtme.NotaParcial1
END AS mo
WHEN espera una única expresión booleana. No se pueden concatenar condiciones sin un operador lógico (AND, OR) que las una. La simplificación directa sin AND es sintácticamente incorrecta.
La forma correcta de simplificar la lógica anidada es utilizando el operador AND:
CASE
WHEN p.subject = 130 AND mp.subject IN (290,291,292,293) THEN mtme.NotaParcial1
END AS mo
Esta versión es lógicamente equivalente a la anidada, pero mucho más concisa y legible.
3. Manejo de Fechas y Timestamps Unix
Otro escenario común es la discrepancia en la visualización de fechas entre SQL Server y la aplicación. Tenía una columna de tipo DATE en SQL Server que, al ser consumida por una aplicación ASP.NET, se mostraba con la hora por defecto (00:00:00.000). Mi objetivo era que solo se mostrara la fecha.
3.1. Solución para Campos DATE en SQL Server
Aunque el campo en SQL Server fuera DATE (sin hora), el framework de .NET lo convertía a DateTime, añadiendo la hora. La solución más robusta es convertirlo explícitamente a un formato de texto en la propia consulta SQL:
CONVERT(char(10), tu_campo_fecha, 120) AS FechaSinHora
El estilo 120 (yyyy-mm-dd) es universalmente compatible. Alternativamente, se puede usar CAST(tu_campo_fecha AS DATE), pero la conversión a char asegura que la aplicación lo reciba como texto y no intente interpretarlo como DateTime.
3.2. Conversión de Timestamps Unix (BIGINT) a Fechas Legibles
Un desafío más complejo surgió con una columna timecreated que almacenaba un timestamp Unix (BIGINT), representando el número de segundos desde 1970-01-01 00:00:00 UTC. Para convertirlo a un formato de fecha y hora legible en SQL Server, utilicé DATEADD:
DATEADD(SECOND, rp.timecreated, '1970-01-01') AS timecreated_readable
Si además quería que este resultado fuera solo la fecha (sin hora), combiné DATEADD con CAST AS DATE:
CAST(DATEADD(SECOND, rp.timecreated, '1970-01-01') AS DATE) AS timecreated
4. Refinando un Procedimiento Almacenado con Parámetros
Finalmente, trabajé en la conversión de una vista a un procedimiento almacenado para permitir filtros dinámicos. La estructura general de la vista era robusta, utilizando Common Table Expressions (CTEs) para organizar la lógica.
WITH active_students AS(
SELECT DISTINCT
student
FROM academic_status
WHERE @status IS NULL OR status = @status
),
students AS(
SELECT
IdEstudiante AS id,
NoCarne AS code,
CONCAT_WS(' ', Nombre1, Nombre2, Apellido1, Apellido2) AS fullname,
CatCarreras.IdCatCarreras AS career_id,
CatCarreras.Nombre AS career_name
FROM Estudiante
INNER JOIN CatCarreras ON Estudiante.IdCatCarreras = CatCarreras.IdCatCarreras
INNER JOIN active_students ON Estudiante.IdEstudiante = active_students.student
WHERE Estudiante.IdCatCarreras = 1
AND @student IS NULL OR Estudiante.IdEstudiante = @student
)
SELECT
rp.idnumber,
rp.student_id,
s.code,
s.fullname,
s.career_name,
rp.term_id,
rp.usercreated AS usercreated_id,
au.UserName AS username,
DATEADD(SECOND, rp.timecreated, '1970-01-01') AS timecreated,
COUNT(tme.IdMateriaEstudiante) AS planned_rotations,
SUM(
CASE
WHEN tme.rotation_startdate IS NOT NULL THEN 1 ELSE 0
END
)AS started_rotations,
CONCAT_WS(' ', e.firstname, e.lastname) AS teacher_name,
rt.name AS hospital_name
FROM rotation_planification rp
INNER JOIN students s ON rp.student_id = s.id
LEFT JOIN tblMateriaEstudiante tme ON rp.student_id = tme.IdEstudiante AND rp.term_id = tme.term
INNER JOIN aspnet_Users au ON rp.usercreated = au.UserId
INNER JOIN employees e ON tme.teacher_id = e.idnumber
INNER JOIN rotation_hospitals rt ON tme.hospital_id = rt.idnumber
WHERE
(@term IS NULL OR rp.term_id = @term)
OR
(@planification IS NULL OR rp.idnumber IN (SELECT item FROM Split(@planification, ',')))
GROUP BY
rp.idnumber,
rp.student_id,
s.code,
s.fullname,
s.career_name,
rp.term_id,
rp.usercreated,
au.UserName,
rp.timecreated,
e.firstname,
e.lastname,
rt.name
4.1. El Problema de los Filtros Opcionales con OR
Al probar el procedimiento almacenado pasando todos los parámetros como NULL, noté que se devolvían todos los registros, lo cual no era el comportamiento deseado. La causa estaba en la cláusula WHERE:
WHERE
(@term IS NULL OR rp.term_id = @term)
OR
(@planification IS NULL OR rp.idnumber IN (SELECT item FROM Split(@planification, ',')))
NULL, las condiciones @param IS NULL se evalúan como TRUE. Si estas condiciones se unen con OR, la cláusula WHERE completa se convierte en TRUE OR TRUE, lo que significa que todos los registros de la tabla base son devueltos. Este es un error común de lógica en filtros dinámicos.
4.2. Corrección de la Precedencia en CTEs
Antes de abordar el problema principal, identifiqué un error de precedencia similar en el CTE students:
WHERE Estudiante.IdCatCarreras = 1
AND @student IS NULL OR Estudiante.IdEstudiante = @student
Esto se interpreta como (Estudiante.IdCatCarreras = 1 AND @student IS NULL) OR Estudiante.IdEstudiante = @student. La solución es agrupar la condición del parámetro con paréntesis:
WHERE Estudiante.IdCatCarreras = 1
AND (@student IS NULL OR Estudiante.IdEstudiante = @student)
4.3. La Solución Final para Filtros Opcionales
Para lograr que el procedimiento no devolviera registros cuando no se pasaba ningún filtro (es decir, todos los parámetros eran NULL), la lógica de la cláusula WHERE debía ser más estricta. Mi solución final fue cambiar la lógica para que solo se aplicara un filtro si el parámetro correspondiente no era NULL, y unir estas condiciones con OR:
WHERE
(@term IS NOT NULL AND rp.term_id = @term)
OR
(@planification IS NOT NULL AND rp.idnumber IN (SELECT item FROM Split(@planification, ',')))
Con esta modificación, si tanto @term como @planification son NULL, ambas partes de la condición WHERE se evalúan como FALSE, resultando en FALSE OR FALSE, lo que efectivamente no devuelve ningún registro. Si al menos uno de los parámetros tiene un valor, su condición se activa y filtra los resultados.
La historia detrás de la nota
Este recorrido por el T-SQL me recordó la importancia de la precisión en cada detalle, desde el tipo de dato hasta la lógica booleana. Cada pequeño ajuste, como el uso correcto de AND/OR o la conversión explícita, es clave para la robustez de nuestras aplicaciones. ¡Un buen recordatorio para German y para todos!