Jhonatan Pinheiro
Cargando página...
Jhonatan Pinheiro
Cargando página...
Jhonatan Pinheiro
Cargando página...
T-SQL desde el SELECT hasta las funciones de ventana, Vistas y vistas indexadas, Procedimientos con TRY/CATCH y transacciones, los tres tipos de Función y la trampa de la escalar, investigación de datos inconsistentes, plan de ejecución, cargas con SSIS, informes paginados con SSRS, esquema estrella con SCD tipo 2, Power BI, Excel y los ERP Dynamics AX y Navision por dentro.
Existe un perfil de vacante que se repite en el mercado brasileño: analista de BI y bases de datos en la stack Microsoft. El texto cambia, las herramientas no — T-SQL, Views, Stored Procedures, Functions, SSIS, SSRS, Excel y, como valor añadido, Power BI, un Data Warehouse y un ERP como Dynamics AX o Navision.
Esta guía cubre ese conjunto entero, en el orden en que aparece en el día a día: primero el lenguaje, después los objetos que creas dentro de la base, después la investigación de datos erróneos, después las herramientas de carga y de informes, y por último la capa analítica.
Todos los ejemplos son T-SQL de verdad, comprobables en SQL Server. Donde el comportamiento cambia entre versiones, está anotado.
No memorices la sintaxis. Entiende dónde encaja cada pieza — la sintaxis se consulta, el encaje hay que saberlo de memoria para elegir la herramienta correcta en el momento correcto.
Antes del código, el mapa. Una operación de BI en la stack Microsoft suele tener cuatro capas:
| Capa | Herramienta | Qué hace |
|---|---|---|
| Origen | ERP, sistemas, hojas de cálculo, APIs | Donde nace el dato |
| Movimiento | SSIS | Extrae, transforma y carga (ETL) |
| Almacenamiento | SQL Server (OLTP y Data Warehouse) | Guarda y consulta |
| Consumo | SSRS, Power BI, Excel | Entrega el número a quien decide |
El error clásico de quien empieza es creer que esas capas compiten. No compiten — cada una resuelve un problema que las otras resuelven mal:
Si la petición es "necesito el informe de cierre en PDF cada día 1 a las 6 en el correo de la dirección", la respuesta es SSRS. Si es "quiero entender por qué cayó el margen en el Sur", la respuesta es Power BI. Confundirlo genera meses de retrabajo.
T-SQL es el dialecto SQL de Microsoft. Todo lo que viene después — un procedimiento, una vista, un paquete de SSIS, un dataset de informe — es T-SQL por debajo.
SELECT ped.PedidoID,
cli.Nome,
ped.DataPedido,
ped.ValorTotal
FROM dbo.Pedidos AS ped
JOIN dbo.Clientes AS cli ON cli.ClienteID = ped.ClienteID
WHERE ped.DataPedido >= '2026-01-01'
AND ped.Status = 'FATURADO'
ORDER BY ped.DataPedido DESC;Tres hábitos que separan el código profesional del improvisado:
dbo.Pedidos, no Pedidos). Sin eso, SQL Server busca primero en el esquema del usuario, y el plan de ejecución no se reaprovecha entre usuarios distintos.Nome a Pedidos dentro de dos años, tu consulta no se romperá con un "ambiguous column name".SELECT * en código que va a producción. Trae columnas que no usas, se rompe cuando la tabla cambia e impide que el optimizador use índices de cobertura.Esta es la consulta que aparece en toda base lenta:
-- MAL: la función sobre la columna impide el uso del índice
SELECT * FROM dbo.Pedidos
WHERE YEAR(DataPedido) = 2026 AND MONTH(DataPedido) = 3;Aplicar una función a la columna vuelve el predicado no SARGable — SQL Server necesita calcular YEAR() en cada fila de la tabla para saber cuáles sirven. El índice de DataPedido se convierte en decoración.
-- BIEN: intervalo abierto por arriba, y el índice se usa
SELECT PedidoID, ClienteID, ValorTotal
FROM dbo.Pedidos
WHERE DataPedido >= '2026-03-01'
AND DataPedido < '2026-04-01';Fíjate en el < del límite superior en lugar de BETWEEN '2026-03-01' AND '2026-03-31'. Con datetime, el BETWEEN pierde todo lo que ocurrió entre 2026-03-31 00:00:00.001 y 23:59:59.997. El intervalo semiabierto siempre está bien, con cualquier tipo de fecha.
Y usa siempre el formato AAAAMMDD o AAAA-MM-DD en los literales: son los únicos que SQL Server interpreta igual con cualquier configuración de idioma.
-- La trampa clásica: filtrar la tabla de la derecha en el WHERE
SELECT cli.Nome, ped.PedidoID
FROM dbo.Clientes AS cli
LEFT JOIN dbo.Pedidos AS ped ON ped.ClienteID = cli.ClienteID
WHERE ped.Status = 'FATURADO'; -- <- descarta a los clientes sin pedidoEl WHERE se ejecuta después del JOIN. Un cliente sin pedido tiene ped.Status nulo, y NULL = 'FATURADO' es falso — el cliente desaparece del resultado, y el LEFT JOIN se convierte en un INNER JOIN en la práctica.
-- Bien: la condición de la tabla de la derecha va en el ON
SELECT cli.Nome, ped.PedidoID
FROM dbo.Clientes AS cli
LEFT JOIN dbo.Pedidos AS ped
ON ped.ClienteID = cli.ClienteID
AND ped.Status = 'FATURADO';WITH crea un resultado con nombre y temporal. Sirve para partir una consulta grande en pasos legibles:
WITH VendasMes AS (
SELECT VendedorID,
EOMONTH(DataPedido) AS Mes,
SUM(ValorTotal) AS Total
FROM dbo.Pedidos
WHERE Status = 'FATURADO'
GROUP BY VendedorID, EOMONTH(DataPedido)
),
Ranking AS (
SELECT *,
RANK() OVER (PARTITION BY Mes ORDER BY Total DESC) AS Posicao
FROM VendasMes
)
SELECT ven.Nome, r.Mes, r.Total, r.Posicao
FROM Ranking AS r
JOIN dbo.Vendedores AS ven ON ven.VendedorID = r.VendedorID
WHERE r.Posicao <= 3
ORDER BY r.Mes, r.Posicao;Una salvedad honesta: una CTE no es una tabla temporal. No materializa nada — el optimizador expande su texto allí donde se usa. Si referencias la misma CTE tres veces, se ejecuta tres veces. Cuando el resultado intermedio es caro y se reutiliza, una tabla #temporal suele ser más rápida.
WITH Hierarquia AS (
-- ancla: la cima del árbol
SELECT FuncionarioID, Nome, GerenteID, 0 AS Nivel
FROM dbo.Funcionarios
WHERE GerenteID IS NULL
UNION ALL
-- recursión: cada nivel trae el siguiente
SELECT f.FuncionarioID, f.Nome, f.GerenteID, h.Nivel + 1
FROM dbo.Funcionarios AS f
JOIN Hierarquia AS h ON h.FuncionarioID = f.GerenteID
)
SELECT REPLICATE(' ', Nivel) + Nome AS Estrutura, Nivel
FROM Hierarquia
ORDER BY Nivel
OPTION (MAXRECURSION 100);El MAXRECURSION es un tope de seguridad: sin él, un ciclo en los datos (A es jefe de B, B es jefe de A) se ejecuta hasta reventar.
Las window functions calculan sobre un conjunto de filas sin colapsar el resultado, a diferencia del GROUP BY.
SELECT PedidoID,
ClienteID,
DataPedido,
ValorTotal,
-- el total acumulado del cliente a lo largo del tiempo
SUM(ValorTotal) OVER (PARTITION BY ClienteID ORDER BY DataPedido
ROWS UNBOUNDED PRECEDING) AS Acumulado,
-- cuánto fue el pedido anterior del mismo cliente
LAG(ValorTotal) OVER (PARTITION BY ClienteID ORDER BY DataPedido) AS PedidoAnterior,
-- la posición del pedido dentro del cliente
ROW_NUMBER() OVER (PARTITION BY ClienteID ORDER BY DataPedido DESC) AS Ordem
FROM dbo.Pedidos;La cláusula ROWS UNBOUNDED PRECEDING importa más de lo que parece. Sin ella, el valor por defecto es RANGE UNBOUNDED PRECEDING, que trata los valores empatados como un solo bloque y usa un spool en disco más lento. Con fechas repetidas, los dos dan resultados distintos. Escribe ROWS siempre que la intención sea "fila a fila".
CROSS APPLY y OUTER APPLY son exclusivos de SQL Server y resuelven el clásico "el último registro de cada grupo":
SELECT cli.Nome,
ult.PedidoID,
ult.DataPedido,
ult.ValorTotal
FROM dbo.Clientes AS cli
OUTER APPLY (
SELECT TOP (1) ped.PedidoID, ped.DataPedido, ped.ValorTotal
FROM dbo.Pedidos AS ped
WHERE ped.ClienteID = cli.ClienteID -- ve la fila de fuera
ORDER BY ped.DataPedido DESC
) AS ult;OUTER APPLY mantiene al cliente aunque no tenga pedidos (como LEFT JOIN); CROSS APPLY lo descarta (como INNER JOIN).
MERGE hace un insert, un update y un delete en una sola sentencia. Es elegante y tiene un historial de bugs documentados por la propia Microsoft, sobre todo con tablas particionadas, triggers y claves foráneas.
-- Una alternativa segura y previsible al MERGE
BEGIN TRANSACTION;
UPDATE dest
SET dest.Nome = orig.Nome,
dest.Email = orig.Email,
dest.AtualizadoEm = SYSDATETIME()
FROM dbo.Clientes AS dest
JOIN stg.Clientes AS orig ON orig.ClienteID = dest.ClienteID
WHERE dest.Nome <> orig.Nome OR dest.Email <> orig.Email;
INSERT INTO dbo.Clientes (ClienteID, Nome, Email, AtualizadoEm)
SELECT orig.ClienteID, orig.Nome, orig.Email, SYSDATETIME()
FROM stg.Clientes AS orig
WHERE NOT EXISTS (SELECT 1 FROM dbo.Clientes AS dest
WHERE dest.ClienteID = orig.ClienteID);
COMMIT;El WHERE dest.Nome <> orig.Nome OR ... del UPDATE no es un detalle: sin él reescribes filas idénticas, generas log de transacción para nada y disparas triggers sin necesidad.
Una vista es una consulta guardada con un nombre. No guarda datos — cada vez que la consultas, la consulta que hay detrás se ejecuta.
CREATE OR ALTER VIEW dbo.vw_PedidosFaturados
AS
SELECT ped.PedidoID,
ped.ClienteID,
cli.Nome AS ClienteNome,
ped.DataPedido,
ped.ValorTotal
FROM dbo.Pedidos AS ped
JOIN dbo.Clientes AS cli ON cli.ClienteID = ped.ClienteID
WHERE ped.Status = 'FATURADO';
GOPara qué sirve de verdad:
Dónde se convierte en un problema: una vista sobre una vista sobre una vista. Cada capa parece ordenada, y el plan de ejecución final se vuelve un monstruo que lee tablas que nadie pidió. Dos capas es razonable; cuatro es deuda técnica.
Con SCHEMABINDING y un índice clusterizado único, el resultado se materializa en disco y SQL Server lo mantiene actualizado:
CREATE OR ALTER VIEW dbo.vw_VendasPorDia
WITH SCHEMABINDING
AS
SELECT CAST(DataPedido AS date) AS Dia,
COUNT_BIG(*) AS Pedidos,
SUM(ValorTotal) AS Total
FROM dbo.Pedidos -- esquema obligatorio con SCHEMABINDING
WHERE Status = 'FATURADO'
GROUP BY CAST(DataPedido AS date);
GO
CREATE UNIQUE CLUSTERED INDEX IX_vw_VendasPorDia ON dbo.vw_VendasPorDia (Dia);Reglas que pillan a todo el mundo por sorpresa: COUNT_BIG(*) es obligatorio en una vista agregada (no COUNT(*)), el SUM sobre una columna que admite NULL está prohibido, y nada de OUTER JOIN, subconsulta o DISTINCT. A cambio, una agregación que tardaba minutos pasa a responder al instante — a costa de dejar cada INSERT en la tabla base un poco más lento.
Un procedimiento es código T-SQL guardado en la base, con parámetros. Es donde vive la lógica de carga, validación y regla de negocio.
CREATE OR ALTER PROCEDURE dbo.usp_FaturarPedido
@PedidoID INT,
@UsuarioID INT,
@Faturado BIT = 0 OUTPUT
AS
BEGIN
-- Sin esto, cada sentencia devuelve "(N rows affected)" y algunos drivers
-- (incluido el de SSIS) tratan ese mensaje como un resultado más.
SET NOCOUNT ON;
-- Garantiza que cualquier error aborte toda la transacción, y no solo la sentencia.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
IF NOT EXISTS (SELECT 1 FROM dbo.Pedidos WHERE PedidoID = @PedidoID)
BEGIN
THROW 50001, 'Pedido não encontrado.', 1;
END
UPDATE dbo.Pedidos
SET Status = 'FATURADO',
FaturadoEm = SYSDATETIME(),
FaturadoPor = @UsuarioID
WHERE PedidoID = @PedidoID
AND Status = 'ABERTO'; -- idempotente: refacturar no hace nada
SET @Faturado = CASE WHEN @@ROWCOUNT > 0 THEN 1 ELSE 0 END;
INSERT INTO dbo.LogFaturamento (PedidoID, UsuarioID, OcorridoEm)
VALUES (@PedidoID, @UsuarioID, SYSDATETIME());
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
INSERT INTO dbo.LogErro (Rotina, Mensagem, Numero, Linha, OcorridoEm)
VALUES ('usp_FaturarPedido', ERROR_MESSAGE(), ERROR_NUMBER(),
ERROR_LINE(), SYSDATETIME());
THROW; -- relanza preservando el número, el mensaje y la severidad
END CATCH
END
GOCinco detalles de ese bloque que marcan la diferencia:
SET NOCOUNT ON elimina los mensajes de recuento que confunden a las aplicaciones y a los paquetes de SSIS.SET XACT_ABORT ON garantiza el rollback en errores que, por sí solos, no abortarían la transacción — dejándola abierta y bloqueando la tabla.XACT_STATE() distingue una transacción todavía válida de una ya condenada; IF @@TRANCOUNT > 0 por sí solo no cubre ese caso.THROW sin argumentos relanza el error original. RAISERROR exige reconstruir el mensaje y pierde el número del error.AND Status = 'ABERTO' hace idempotente el procedimiento: llamarlo dos veces no factura dos veces. En una integración, eso vale oro.SQL Server compila el plan del procedimiento a partir del primer valor que recibe y lo reutiliza. Si la primera llamada fue @ClienteID = 12345 (tres pedidos) y la siguiente es para un cliente con dos millones de pedidos, el plan optimizado para tres filas se usa para dos millones.
-- Recompila solo esta sentencia en cada ejecución; el resto del procedimiento sigue en caché
SELECT PedidoID, ValorTotal
FROM dbo.Pedidos
WHERE ClienteID = @ClienteID
OPTION (RECOMPILE);Alternativas según el caso: OPTIMIZE FOR UNKNOWN (usa la media de las estadísticas), copiar el parámetro a una variable local (efecto parecido) o, en SQL Server 2022+, dejar que el Parameter Sensitive Plan optimization lo resuelva solo.
Cuando el procedimiento se puso lento "sin que nadie tocara nada", el parameter sniffing es el primer sospechoso.
En lugar de llamar al procedimiento mil veces, manda las mil filas de una vez:
CREATE TYPE dbo.TipoItemPedido AS TABLE (
ProdutoID INT NOT NULL,
Quantidade INT NOT NULL,
ValorUnit DECIMAL(18,2) NOT NULL
);
GO
CREATE OR ALTER PROCEDURE dbo.usp_InserirItens
@PedidoID INT,
@Itens dbo.TipoItemPedido READONLY -- un TVP es siempre READONLY
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.PedidoItens (PedidoID, ProdutoID, Quantidade, ValorUnit)
SELECT @PedidoID, ProdutoID, Quantidade, ValorUnit
FROM @Itens;
END
GOSQL Server tiene tres tipos de función, y elegir el equivocado cuesta horas de ejecución.
Devuelve un valor único. Parece inofensiva y es la mayor asesina de rendimiento de la stack:
CREATE OR ALTER FUNCTION dbo.fn_CalculaDesconto (@Valor DECIMAL(18,2))
RETURNS DECIMAL(18,2)
AS
BEGIN
RETURN CASE WHEN @Valor > 1000 THEN @Valor * 0.10 ELSE 0 END;
END
GOUsada en un SELECT sobre un millón de filas, se la llama un millón de veces, una por fila, y hasta SQL Server 2017 impedía el paralelismo en toda la consulta. SQL Server 2019 introdujo el inlining automático de escalares, que resuelve algunos casos — no todos, y no siempre controlas la versión del servidor.
Un RETURN con una única consulta. El optimizador la expande dentro de la consulta que la llama, como si fuera una vista parametrizada. Coste prácticamente cero:
CREATE OR ALTER FUNCTION dbo.tvf_PedidosDoCliente (@ClienteID INT)
RETURNS TABLE
AS
RETURN
(
SELECT PedidoID, DataPedido, ValorTotal
FROM dbo.Pedidos
WHERE ClienteID = @ClienteID
AND Status = 'FATURADO'
);
GO
-- Uso natural con APPLY
SELECT cli.Nome, ped.PedidoID, ped.ValorTotal
FROM dbo.Clientes AS cli
CROSS APPLY dbo.tvf_PedidosDoCliente(cli.ClienteID) AS ped;Fíjate en que no hay BEGIN/END — es esa ausencia la que la hace inline. La misma lógica escrita con BEGIN ... RETURN ... END se convierte en multi-statement y pierde la optimización.
Declara una variable de tabla, la rellena en varios pasos y la devuelve. El optimizador no ve lo que hay dentro y estima un número fijo de filas (1 hasta SQL Server 2012, 100 después), lo que produce planes malos:
CREATE OR ALTER FUNCTION dbo.mstvf_Resumo (@Ano INT)
RETURNS @Resultado TABLE (Mes INT, Total DECIMAL(18,2))
AS
BEGIN
INSERT INTO @Resultado (Mes, Total)
SELECT MONTH(DataPedido), SUM(ValorTotal)
FROM dbo.Pedidos
WHERE DataPedido >= DATEFROMPARTS(@Ano, 1, 1)
AND DataPedido < DATEFROMPARTS(@Ano + 1, 1, 1)
GROUP BY MONTH(DataPedido);
RETURN;
END
GORegla práctica: si cabe en una sola consulta, hazla inline TVF. Si necesita varios pasos, prefiere un procedimiento que escriba en una tabla #temporal.
Buena parte del trabajo no es escribir una consulta nueva — es descubrir por qué el número del informe no cuadra. Estas consultas resuelven la mayoría de los casos.
-- Qué claves están duplicadas y cuántas veces
SELECT CPF, COUNT(*) AS Qtd
FROM dbo.Clientes
WHERE CPF IS NOT NULL
GROUP BY CPF
HAVING COUNT(*) > 1
ORDER BY Qtd DESC;Para borrar manteniendo el más reciente, ROW_NUMBER es la forma segura — el DELETE sobre la CTE borra en la tabla base:
WITH Duplicadas AS (
SELECT ClienteID,
ROW_NUMBER() OVER (PARTITION BY CPF ORDER BY CriadoEm DESC) AS rn
FROM dbo.Clientes
WHERE CPF IS NOT NULL
)
DELETE FROM Duplicadas WHERE rn > 1;Antes de cualquier
DELETEen producción: ejecútalo comoSELECTprimero, comprueba el recuento y haz una copia de seguridad. UnDELETEcon elWHEREequivocado es el error más caro que existe.
SELECT ped.PedidoID, ped.ClienteID
FROM dbo.Pedidos AS ped
WHERE NOT EXISTS (SELECT 1 FROM dbo.Clientes AS cli
WHERE cli.ClienteID = ped.ClienteID);Prefiere NOT EXISTS a NOT IN: si la subconsulta del NOT IN devuelve un único NULL, el resultado entero se queda vacío, en silencio. Es uno de los bugs más difíciles de ver en SQL.
-- Reconciliación: la cabecera contra la suma de las líneas
SELECT ped.PedidoID,
ped.ValorTotal AS TotalCabecalho,
SUM(itm.Quantidade * itm.ValorUnit) AS TotalItens,
ped.ValorTotal - SUM(itm.Quantidade * itm.ValorUnit) AS Diferenca
FROM dbo.Pedidos AS ped
JOIN dbo.PedidoItens AS itm ON itm.PedidoID = ped.PedidoID
GROUP BY ped.PedidoID, ped.ValorTotal
HAVING ABS(ped.ValorTotal - SUM(itm.Quantidade * itm.ValorUnit)) > 0.01;El > 0.01 en lugar de <> 0 no es pereza: con float o con el redondeo de la moneda, aparecen diferencias de un céntimo por la representación numérica y ensucian el resultado. Usa DECIMAL para el dinero — nunca float.
-- 'ABC ' y 'ABC' parecen iguales en pantalla y pueden no casar en el JOIN
SELECT ClienteID, '[' + Codigo + ']' AS ComDelimitador, LEN(Codigo) AS Tamanho,
DATALENGTH(Codigo) AS Bytes
FROM dbo.Clientes
WHERE Codigo <> LTRIM(RTRIM(Codigo));LEN() ignora los espacios finales; DATALENGTH() no. Cuando los dos discrepan, has encontrado el problema. En SQL Server 2017+, TRIM() hace los dos lados de una vez.
Y cuidado con la collation: si una tabla es SQL_Latin1_General_CP1_CI_AS y la otra ..._CS_AS, el JOIN entre ellas falla con un error de conflicto de collation — o peor, casa de forma distinta a lo esperado (CI ignora las mayúsculas, CS no).
-- Descubrir la collation de cada columna
SELECT c.name AS Coluna, c.collation_name
FROM sys.columns AS c
WHERE c.object_id = OBJECT_ID('dbo.Clientes')
AND c.collation_name IS NOT NULL;-- Corrupción en disco: ejecútalo en ventana de mantenimiento, es pesado
DBCC CHECKDB ('MinhaBase') WITH NO_INFOMSGS, ALL_ERRORMSGS;
-- Confianza de las claves foráneas: 0 = fiable, 1 = no validada
SELECT name, is_not_trusted
FROM sys.foreign_keys
WHERE is_not_trusted = 1;Una FK "no fiable" ocurre después de un BULK INSERT o de un ALTER TABLE ... NOCHECK. El optimizador deja de usarla para simplificar planes, y deja de garantizar lo que promete. Para revalidarla:
ALTER TABLE dbo.Pedidos WITH CHECK CHECK CONSTRAINT FK_Pedidos_Clientes;El WITH CHECK CHECK duplicado no es una errata: el primero manda validar los datos existentes, el segundo vuelve a activar la restricción.
SET STATISTICS IO, TIME ON;
-- tu consulta aquí
SET STATISTICS IO, TIME OFF;STATISTICS IO muestra cuántas páginas se leyeron por tabla. Es el número más honesto que existe: el tiempo varía con la carga de la máquina, las lecturas lógicas no. Si cambiaste la consulta y las lecturas bajaron de 400.000 a 300, mejoró de verdad.
En SSMS, Ctrl+M activa el plan de ejecución real. Busca:
INCLUDE.UPDATE STATISTICS dbo.Pedidos WITH FULLSCAN;CREATE NONCLUSTERED INDEX IX_Pedidos_Cliente_Data
ON dbo.Pedidos (ClienteID, DataPedido) -- columnas de filtro y ordenación
INCLUDE (ValorTotal, Status); -- columnas que solo se devuelvenEl orden de las columnas en la clave importa: (ClienteID, DataPedido) sirve para filtrar por cliente, y por cliente + fecha; no sirve para filtrar solo por fecha. Piénsalo como el orden de las palabras en el índice de un libro.
SELECT TOP (20)
qs.total_worker_time / qs.execution_count / 1000 AS CpuMedioMs,
qs.execution_count AS Execucoes,
qs.total_logical_reads / qs.execution_count AS LeiturasMedias,
SUBSTRING(st.text, (qs.statement_start_offset/2) + 1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2) + 1) AS Consulta
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
ORDER BY qs.total_worker_time DESC;En SQL Server 2016+, activa el Query Store en la base: guarda el histórico de planes y permite forzar el plan bueno cuando uno regresa.
SSIS (SQL Server Integration Services) es la herramienta de ETL de Microsoft. Diseñas el paquete en Visual Studio (con la extensión SQL Server Integration Services Projects) y se ejecuta en el servidor, programado por el SQL Server Agent.
Un paquete tiene dos superficies, y confundirlas es el error nº 1 de quien empieza:
Regla de oro: lo que se pueda hacer en SQL, hazlo en SQL. Una transformación Sort de SSIS carga todo en la RAM del servidor de integración; un ORDER BY en el origen usa el índice de la base. Usa el Data Flow para mover datos y para lo que la base hace mal (leer un archivo, llamar a una API, repartir a varios destinos).
Casi toda carga seria sigue tres etapas:
stg.*, sin transformar nada. Si algo sale mal, tienes el material original para investigar.Execute SQL Task). Es más rápido, más fácil de probar y se versiona mejor que los componentes gráficos.Para una carga incremental, guarda una marca de agua en lugar de releer la tabla entera:
-- Tabla de control
CREATE TABLE dbo.ControleCarga (
Tabela SYSNAME NOT NULL PRIMARY KEY,
UltimaCarga DATETIME2(3) NOT NULL,
LinhasCarregadas INT NOT NULL DEFAULT 0
);
-- En el origen, el paquete lee solo lo que cambió desde la última vez
DECLARE @Desde DATETIME2(3) = (SELECT UltimaCarga FROM dbo.ControleCarga
WHERE Tabela = 'Pedidos');
SELECT PedidoID, ClienteID, DataPedido, ValorTotal, AlteradoEm
FROM dbo.Pedidos
WHERE AlteradoEm > @Desde;Un detalle que evita perder datos: graba como nueva marca de agua la hora de inicio de la carga, no la del final. Los registros escritos en el origen mientras el paquete se ejecutaba caerían en un hueco ciego si usaras la hora final.
Todo componente del Data Flow tiene una salida de error (la flecha roja). Configúrala como Redirect Row y manda las filas problemáticas a una tabla de rechazados, en lugar de tumbar el paquete entero:
CREATE TABLE stg.LinhasRejeitadas (
RejeitadaID INT IDENTITY PRIMARY KEY,
Pacote SYSNAME NOT NULL,
LinhaOriginal NVARCHAR(MAX) NOT NULL,
ErroCodigo INT,
ErroColuna INT,
OcorridoEm DATETIME2(3) DEFAULT SYSDATETIME()
);Una factura con un identificador fiscal inválido no puede impedir que las otras 50 mil entren. Carga lo que es válido, aparta lo que no lo es, y manda la lista a quien la dio de alta.
-- Comprobaciones que deberían ejecutarse al final de toda carga
DECLARE @Erros TABLE (Verificacao VARCHAR(100), Qtd INT);
INSERT INTO @Erros
SELECT 'Pedidos sem cliente', COUNT(*)
FROM stg.Pedidos AS s
WHERE NOT EXISTS (SELECT 1 FROM dbo.Clientes c WHERE c.ClienteID = s.ClienteID)
UNION ALL
SELECT 'Valores negativos', COUNT(*) FROM stg.Pedidos WHERE ValorTotal < 0
UNION ALL
SELECT 'Datas no futuro', COUNT(*) FROM stg.Pedidos WHERE DataPedido > SYSDATETIME()
UNION ALL
SELECT 'Contagem origem x destino',
ABS((SELECT COUNT(*) FROM stg.Pedidos) - (SELECT COUNT(*) FROM dbo.Pedidos
WHERE CargaID = @CargaID));
IF EXISTS (SELECT 1 FROM @Erros WHERE Qtd > 0)
THROW 50100, 'Validação de carga falhou. Veja dbo.LogValidacao.', 1;Una carga que no valida es una carga que miente. La validación es lo que convierte "el paquete se ejecutó" en "el dato está bien".
Desde SQL Server 2012, usa el Project Deployment Model: el proyecto se convierte en un .ispac publicado en el SSIS Catalog (SSISDB). Ahí creas Environments — Dev, Preproducción, Producción — cada uno con sus valores de conexión, y vinculas la ejecución al entorno.
Nunca dejes una cadena de conexión fija dentro del paquete. Además del riesgo de ejecutar en producción creyendo que era una prueba, una contraseña en un paquete es una contraseña en un archivo versionado.
Delay Validation = True en las tareas que dependen de objetos creados durante la ejecución, si no el paquete falla en la validación inicial.Fast Load en el destino OLE DB, con Rows per batch y Maximum insert commit size ajustados — la diferencia con el modo fila a fila llega a ser de diez veces.Verbose solo cuando estés cazando un problema (genera un volumen enorme).DFT_CargaPedidos, SQL_TruncaStaging, FLC_ArquivosDoDia. Un paquete con "Data Flow Task 1" y "Execute SQL Task 4" es imposible de mantener.SSRS (SQL Server Reporting Services) genera informes con maquetación fija, hechos para paginar, imprimir y exportar. Los diseñas en Report Builder o en Visual Studio, y los publicas en un portal web.
Todo .rdl tiene cuatro piezas:
CREATE OR ALTER PROCEDURE rpt.usp_VendasPorPeriodo
@DataInicio DATE,
@DataFim DATE,
@VendedorID INT = NULL -- NULL = todos
AS
BEGIN
SET NOCOUNT ON;
SELECT ven.Nome AS Vendedor,
cli.Nome AS Cliente,
ped.PedidoID,
ped.DataPedido,
ped.ValorTotal
FROM dbo.Pedidos AS ped
JOIN dbo.Clientes AS cli ON cli.ClienteID = ped.ClienteID
JOIN dbo.Vendedores AS ven ON ven.VendedorID = ped.VendedorID
WHERE ped.DataPedido >= @DataInicio
AND ped.DataPedido < DATEADD(DAY, 1, @DataFim) -- incluye el día entero
AND ped.Status = 'FATURADO'
AND (@VendedorID IS NULL OR ped.VendedorID = @VendedorID)
ORDER BY ven.Nome, ped.DataPedido;
END
GODos cosas aquí merecen atención. El DATEADD(DAY, 1, @DataFim) con < resuelve el problema clásico del informe que "se olvida" de los pedidos del último día. Y el (@VendedorID IS NULL OR ...) es el patrón de parámetro opcional — si deja la consulta lenta, añade OPTION (RECOMPILE), que permite al optimizador simplificar el predicado conociendo el valor real.
El segundo parámetro depende del primero: elegir la provincia filtra las ciudades. Crea un dataset para cada lista y, en el de ciudades, usa el parámetro de la provincia:
-- Dataset "Estados"
SELECT DISTINCT UF FROM dbo.Clientes ORDER BY UF;
-- Dataset "Cidades" — recibe @UF del primer parámetro
SELECT DISTINCT Cidade FROM dbo.Clientes WHERE UF = @UF ORDER BY Cidade;Para un parámetro de selección múltiple, SSRS te entrega una lista; del lado del T-SQL, recíbela como texto y usa STRING_SPLIT:
-- @Ufs llega como 'SP,RJ,MG'
SELECT ped.PedidoID, ped.ValorTotal
FROM dbo.Pedidos AS ped
JOIN dbo.Clientes AS cli ON cli.ClienteID = ped.ClienteID
WHERE cli.UF IN (SELECT value FROM STRING_SPLIT(@Ufs, ','));Las expresiones empiezan con = y usan sintaxis Visual Basic:
' El total de un grupo
=Sum(Fields!ValorTotal.Value)
' Formatear moneda
=Format(Fields!ValorTotal.Value, "C2")
' Cebra: alternar el color de fondo de las filas
=IIf(RowNumber(Nothing) Mod 2 = 0, "#F5F5F5", "White")
' Evitar la división por cero — IIf evalúa los dos lados, así que protege el divisor
=IIf(Sum(Fields!Meta.Value) = 0, 0,
Sum(Fields!Realizado.Value) / IIf(Sum(Fields!Meta.Value) = 0, 1, Sum(Fields!Meta.Value)))
' Numeración de páginas en el pie
="Página " & Globals!PageNumber & " de " & Globals!TotalPagesEse IIf anidado en el divisor no es un adorno: a diferencia de un if de un lenguaje normal, el IIf de SSRS evalúa las dos ramas antes de elegir, así que la división por cero ocurre incluso cuando la condición dice que no hay que dividir.
Una subscription programa la ejecución y entrega el resultado por correo o en una carpeta de red. Es lo que atiende el "cada día 1 a las 6, el cierre en el correo de la dirección" sin que nadie haga clic en nada.
La data-driven subscription (edición Enterprise) va más allá: una consulta define quién recibe qué. Una ejecución genera 40 informes, uno por gerente regional, cada uno con el filtro de su región, enviados al correo de cada uno.
Interactive Size y Page Size definen el corte en pantalla y en la impresión; dejar los valores por defecto genera un PDF con páginas en blanco entre cada página real.KeepTogether y RepeatColumnHeaders hacen que el encabezado se repita en cada página — sin ellos, la página 7 es una tabla sin títulos.Cuando el volumen crece y el informe empieza a competir con el sistema transaccional, se separa el dato analítico en un Data Warehouse. El modelo estándar es el star schema: una tabla de hechos en el centro, rodeada de dimensiones.
-- Dimensión: describe, tiene pocas filas y muchas columnas
CREATE TABLE dim.Cliente (
ClienteSK INT IDENTITY PRIMARY KEY, -- surrogate key del DW
ClienteBK INT NOT NULL, -- business key: el ID del sistema de origen
Nome VARCHAR(200) NOT NULL,
Cidade VARCHAR(100) NULL,
UF CHAR(2) NULL,
Segmento VARCHAR(50) NULL,
ValidoDe DATE NOT NULL,
ValidoAte DATE NULL, -- NULL = la versión vigente
Corrente BIT NOT NULL DEFAULT 1
);
-- Hecho: mide, tiene muchas filas y pocas columnas
CREATE TABLE fato.Vendas (
VendaSK BIGINT IDENTITY PRIMARY KEY,
DataSK INT NOT NULL, -- FK a dim.Calendario
ClienteSK INT NOT NULL,
ProdutoSK INT NOT NULL,
Quantidade INT NOT NULL,
ValorBruto DECIMAL(18,2) NOT NULL,
Desconto DECIMAL(18,2) NOT NULL DEFAULT 0,
ValorLiquido AS (ValorBruto - Desconto) PERSISTED
);La surrogate key (ClienteSK) existe para que el DW no dependa del ID del sistema de origen — que puede cambiar, repetirse entre empresas o desaparecer en una migración. La business key se guarda para la trazabilidad.
Si un cliente cambia de segmento, la facturación del año pasado debe seguir sumando en el segmento antiguo. Es lo que resuelve la Dimensión de Cambio Lento tipo 2: en lugar de sobrescribir, cierra la versión actual y abre una nueva.
-- 1) Cierra la versión vigente de lo que cambió
UPDATE dim
SET dim.ValidoAte = CAST(SYSDATETIME() AS DATE),
dim.Corrente = 0
FROM dim.Cliente AS dim
JOIN stg.Cliente AS stg ON stg.ClienteID = dim.ClienteBK
WHERE dim.Corrente = 1
AND (dim.Segmento <> stg.Segmento OR dim.UF <> stg.UF);
-- 2) Abre la nueva versión
INSERT INTO dim.Cliente (ClienteBK, Nome, Cidade, UF, Segmento, ValidoDe, Corrente)
SELECT stg.ClienteID, stg.Nome, stg.Cidade, stg.UF, stg.Segmento,
CAST(SYSDATETIME() AS DATE), 1
FROM stg.Cliente AS stg
WHERE NOT EXISTS (SELECT 1 FROM dim.Cliente AS dim
WHERE dim.ClienteBK = stg.ClienteID AND dim.Corrente = 1);Todo análisis cruza el tiempo, y nadie quiere calcular el trimestre con una función en cada consulta. Genera una tabla de fechas una vez:
CREATE TABLE dim.Calendario (
DataSK INT PRIMARY KEY, -- 20260319
Data DATE NOT NULL UNIQUE,
Ano SMALLINT NOT NULL,
Trimestre TINYINT NOT NULL,
Mes TINYINT NOT NULL,
NomeMes VARCHAR(20) NOT NULL,
DiaSemana TINYINT NOT NULL,
EhDiaUtil BIT NOT NULL
);
-- Rellena 20 años sin bucle, usando una secuencia generada
WITH Numeros AS (
SELECT TOP (7305) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n
FROM sys.all_objects a CROSS JOIN sys.all_objects b
),
Datas AS (
SELECT DATEADD(DAY, n, '2020-01-01') AS Data FROM Numeros
)
INSERT INTO dim.Calendario (DataSK, Data, Ano, Trimestre, Mes, NomeMes, DiaSemana, EhDiaUtil)
SELECT CONVERT(INT, FORMAT(Data, 'yyyyMMdd')),
Data, YEAR(Data), DATEPART(QUARTER, Data), MONTH(Data),
DATENAME(MONTH, Data), DATEPART(WEEKDAY, Data),
CASE WHEN DATEPART(WEEKDAY, Data) IN (1, 7) THEN 0 ELSE 1 END
FROM Datas;El error más común es empezar por los gráficos. Lo que sostiene un informe de Power BI es el modelo: tablas relacionadas en estrella, con una dimensión calendario marcada como tabla de fechas.
Algunas medidas DAX que aparecen en todos los proyectos:
Faturamento = SUM(fVendas[ValorLiquido])
Faturamento Ano Anterior =
CALCULATE([Faturamento], SAMEPERIODLASTYEAR(dCalendario[Data]))
Crescimento % =
DIVIDE([Faturamento] - [Faturamento Ano Anterior], [Faturamento Ano Anterior])
Ticket Médio =
DIVIDE([Faturamento], DISTINCTCOUNT(fVendas[PedidoID]))
Acumulado no Ano =
CALCULATE([Faturamento], DATESYTD(dCalendario[Data]))Usa DIVIDE() en lugar del operador /: trata la división por cero devolviendo vacío, en vez de un error que se propaga por todo el visual.
Sobre el modo de conexión: Import trae los datos dentro del archivo (rápido, pero con programación de actualización) y DirectQuery consulta SQL Server en cada interacción (siempre actualizado, pero traslada la lentitud de la consulta al usuario). Import es el valor por defecto correcto hasta que tengas un motivo concreto para lo contrario.
Excel sigue siendo donde el analista trabaja de verdad. Dos capacidades valen más que cien fórmulas:
Power Query (Datos → Obtener datos) conecta directamente con SQL Server, aplica transformaciones en pasos con nombre y actualiza con un clic. Es ETL dentro de Excel, y sustituye a aquel proceso de copiar y pegar cada mes.
Una tabla dinámica sobre la conexión permite explorar sin traer las filas a la hoja. Combinada con Power Query, entrega una herramienta de análisis sin necesitar una licencia de BI.
Fórmulas que resuelven la mayor parte del trabajo analítico:
=BUSCARX(A2; Base!A:A; Base!C:C; "não encontrado")
=SUMAR.SI.CONJUNTO(Vendas[Valor]; Vendas[UF]; "SP"; Vendas[Mês]; 3)
=CONTAR.SI.CONJUNTO(Pedidos[Status]; "ABERTO")
=SI.ERROR(B2/C2; 0)
=TEXTO(A2; "dd/mm/aaaa")BUSCARX sustituye a BUSCARV con ventajas: busca en cualquier dirección, no se rompe cuando alguien inserta una columna y ya trae el parámetro de "si no lo encuentra".
Quien trabaja con BI acaba consultando la base de datos de un ERP. Los de Microsoft tienen particularidades que provocan errores en quien llega sin saberlas.
DATAAREAID es la columna que separa las empresas dentro de la misma base. Toda consulta necesita filtrar por ella, si no sumas la facturación de todas las empresas del grupo:SELECT SUM(LINEAMOUNT) AS Total
FROM dbo.SALESLINE
WHERE DATAAREAID = 'BR01' -- sin esto, el número sale mal
AND CREATEDDATETIME >= '2026-01-01';RECID es la clave real de las tablas (no el campo de negocio), y RECVERSION controla la concurrencia optimista.SALESTABLE/SALESLINE, PURCHTABLE/PURCHLINE, INVENTTRANS para el movimiento de stock.SALESSTATUS = 3 significa "facturado". El mapeo está en los metadatos de AX, no en la base — documéntalo en una tabla de equivalencias en tu DW.$: CRONUS Brasil Ltda$Customer, CRONUS Brasil Ltda$Sales Header. Eso exige corchetes:SELECT No_, Name, [Country_Region Code]
FROM [CRONUS Brasil Ltda$Customer]
WHERE Blocked = 0;_ al final es como Navision escapa las palabras reservadas (No_ es el "No.").tinyint (0/1), y las fechas "vacías" suelen venir como 1753-01-01, el mínimo del datetime — filtra por eso en lugar de por IS NULL.Recomendación general: nunca consultes la base del ERP directamente desde el informe. Tráela a una staging, normaliza los enums y los nombres, y construye encima de eso. Además de proteger el rendimiento del ERP, aislar esa capa evita que la siguiente actualización del ERP rompa veinte informes de golpe.
Si la empresa tiene datos en AWS, Amazon QuickSight es el equivalente de Power BI en aquel ecosistema. Bastan dos conceptos para situarse:
Se conecta a Redshift, RDS (SQL Server incluido), S3 vía Athena y otros. Quien entiende de modelado dimensional y sabe escribir SQL cambia de herramienta en días — lo que no se transfiere rápido es un modelo de datos bien hecho.
Si estás montando este repertorio desde cero, este es el orden que más rinde:
SET NOCOUNT ON, TRY/CATCH y transacción desde el primer día — se convierte en hábito.Un último consejo, que vale más que cualquier sintaxis de esta guía: desconfía del número que cuadra perfecto a la primera. Cuando el informe coincide exactamente con lo esperado en la primera ejecución, casi siempre hay un filtro escondiendo filas — un INNER JOIN que debería ser LEFT, un WHERE que descarta nulos, una empresa que falta en el DATAAREAID. Reconcilia contra el origen antes de entregar. Es esa comprobación la que construye la confianza de quien depende de tu número para decidir.