Databases 8 min de lectura 17 de octubre de 2025

Redondeo en promedio SQL

Dominando el redondeo de promedios, la conversión de timestamps Unix y la optimización de filtros condicionales en SQL Server.

Solución en "Dos Clics" (TL;DR)

Resolución de problemas de SQL, incluyendo el redondeo de promedios al entero más cercano, la conversión de timestamps Unix a fechas legibles y la mejora de la lógica de filtros condicionales en consultas.

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:

SQL Server
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:

SQL Server
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
Error detectado: Aunque 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:

SQL Server
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:

SQL Server
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:

SQL Server
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
Consejo Práctico: La función 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:

SQL Server
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:

SQL Server
CASE
WHEN p.subject = 130 mp.subject IN (290,291,292,293) THEN mtme.NotaParcial1
END AS mo
Error detectado: En T-SQL, la cláusula 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:

SQL Server
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:

SQL Server
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:

SQL Server
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:

SQL Server
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.

SQL Server (Fragmento de SP)
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:

SQL Server (Cláusula WHERE original)
WHERE
(@term IS NULL OR rp.term_id = @term)
OR
(@planification IS NULL OR rp.idnumber IN (SELECT item FROM Split(@planification, ',')))
Error detectado: Cuando todos los parámetros opcionales son 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:

SQL Server (Error de precedencia)
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:

SQL Server (Precedencia corregida)
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:

SQL Server (Cláusula WHERE final)
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!