Jhonatan Pinheiro
Cargando página...
Jhonatan Pinheiro
Cargando página...
Jhonatan Pinheiro
Cargando página...
Qué es cada cosa, qué significa cada término y cómo se hace: NULL, collation, JOIN, CTE, funciones de ventana, Vista, Procedimiento, Función, índice, plan de ejecución, staging, carga incremental, RDL, esquema estrella, SCD, DAX y las particularidades de Dynamics AX y Navision — 70 respuestas con ejemplos listos para probar.
Esta guía está hecha de preguntas — las que aparecen de verdad cuando alguien empieza a trabajar con SQL Server, T-SQL, SSIS, SSRS y BI. Cada respuesta explica qué significa el término, cómo funciona la cosa por dentro y cómo se hace en la práctica, con un ejemplo listo para probar.
No hace falta leerla en orden. Usa el índice, busca la duda y vuelve cuando aparezca otra.
Los ejemplos usan una base de datos imaginaria de ventas con
Clientes,Pedidos,PedidoItens,ProdutosyVendedores. Todos funcionan en SQL Server 2016 o más nuevo.
Es un SGBD — Sistema Gestor de Bases de Datos — relacional, de Microsoft. Traducido: un programa que se ejecuta en un servidor, guarda datos organizados en tablas y responde a peticiones de lectura y escritura que llegan de otros programas.
Hace cuatro cosas que un archivo corriente no hace: garantiza que dos usuarios escribiendo a la vez no se estorben, garantiza que una operación incompleta no deje basura, controla quién puede ver qué, y encuentra una fila entre millones en milisegundos.
Lo que instalas no es solo la base. El paquete incluye el Database Engine (la base en sí), SSIS (la herramienta de carga de datos), SSRS (informes) y SSAS (cubos analíticos). Son productos separados que suelen convivir.
SQL es el lenguaje estándar para consultar bases relacionales — SELECT, INSERT, UPDATE, DELETE. Funciona en cualquier base, con pequeñas variaciones.
T-SQL (Transact-SQL) es la versión de Microsoft: el SQL estándar más todo lo que permite programar dentro de la base — variables, IF, WHILE, tratamiento de errores, funciones propias.
-- SQL estándar: funciona en cualquier base
SELECT Nome FROM Clientes WHERE UF = 'SP';
-- T-SQL: una variable, un condicional y una función específica de Microsoft
DECLARE @Total INT;
SELECT @Total = COUNT(*) FROM Clientes WHERE UF = 'SP';
IF @Total > 100
PRINT 'Muitos clientes em SP: ' + CAST(@Total AS VARCHAR(10));En la práctica, quien trabaja con SQL Server escribe T-SQL todo el tiempo, sin darse cuenta. TOP, ISNULL, GETDATE() y APPLY son T-SQL, no SQL estándar.
Son tres niveles de organización, del mayor al menor:
VendasDB, RH, Financeiro — cada una aislada de las demás.dbo.Clientes y rpt.Clientes son dos tablas distintas.-- El nombre completo de un objeto: servidor.base.esquema.objeto
SELECT * FROM VendasDB.dbo.Clientes;
-- Crear un esquema para separar lo que es de informes
CREATE SCHEMA rpt;
GOdbo que aparece delante del nombre de las tablas?dbo significa database owner y es el esquema por defecto de toda base de SQL Server. Cuando creas una tabla sin decir el esquema, nace en dbo.
Escribir dbo.Clientes en lugar de solo Clientes no es un preciosismo. Sin el esquema, SQL Server busca primero en el esquema de tu usuario y solo después en dbo — eso cuesta una búsqueda de más y, peor, impide que el plan de ejecución se reaproveche entre usuarios distintos. En una consulta que se ejecuta miles de veces, la diferencia es medible.
Un tipo equivocado causa dos problemas: ocupa espacio de más y produce resultados erróneos. Los casos que más aparecen:
| Situación | Usa | No uses | Por qué |
|---|---|---|---|
| Dinero | DECIMAL(18,2) | FLOAT | FLOAT es aproximado: 0,1 + 0,2 no da exactamente 0,3 |
| Texto solo con letras corrientes | VARCHAR | NVARCHAR | NVARCHAR gasta el doble de bytes |
| Texto en cualquier idioma | NVARCHAR | VARCHAR | VARCHAR pierde los caracteres fuera de la collation |
| Fecha y hora | DATETIME2(3) | DATETIME | DATETIME redondea en bloques de 3,33 ms |
| Solo la fecha | DATE | DATETIME | 3 bytes contra 8, y sin hora que estorbe la comparación |
| Verdadero/falso | BIT | VARCHAR(1) | 1 bit contra 1 byte + el riesgo de 'S'/'s'/'Y' |
CREATE TABLE dbo.Pedidos (
PedidoID INT IDENTITY PRIMARY KEY,
ClienteID INT NOT NULL,
DataPedido DATETIME2(3) NOT NULL,
ValorTotal DECIMAL(18,2) NOT NULL, -- el dinero nunca es FLOAT
Observacao VARCHAR(500) NULL,
Ativo BIT NOT NULL DEFAULT 1
);NULL y por qué se comporta de forma tan extraña?NULL no es cero ni texto vacío. Significa "no se sabe". Y por eso rompe la lógica normal: cualquier comparación con lo desconocido da como resultado desconocido, no verdadero ni falso.
SELECT 1 WHERE NULL = NULL; -- no devuelve nada
SELECT 1 WHERE NULL <> 'texto'; -- tampoco devuelve nadaPara comprobar si algo es nulo, usa IS NULL / IS NOT NULL. Para sustituirlo por un valor, ISNULL o COALESCE:
SELECT Nome,
ISNULL(Telefone, 'não informado') AS Telefone,
COALESCE(Celular, Telefone, 'sem contato') AS MelhorContato
FROM dbo.Clientes
WHERE Email IS NOT NULL;ISNULL acepta dos argumentos; COALESCE acepta varios y devuelve el primero que no sea nulo.
El detalle que más consultas tumba: NOT IN con una subconsulta que contenga un único NULL devuelve vacío, en silencio. Usa NOT EXISTS.
Tú la escribes en un orden, la base la ejecuta en otro. Entender eso explica la mitad de los errores de SQL:
1. FROM / JOIN -> monta el conjunto de filas
2. WHERE -> filtra fila a fila
3. GROUP BY -> agrupa
4. HAVING -> filtra los grupos
5. SELECT -> elige y calcula las columnas
6. ORDER BY -> ordena
7. TOP / OFFSET -> recortaPor eso no puedes usar en el WHERE un alias creado en el SELECT — cuando el WHERE se ejecuta, el SELECT todavía no ha ocurrido:
-- ERROR: 'Margem' aún no existe cuando se evalúa el WHERE
SELECT ValorTotal - Custo AS Margem FROM dbo.Pedidos WHERE Margem > 100;
-- Correcto: repite la expresión, o usa una subconsulta/CTE
SELECT ValorTotal - Custo AS Margem FROM dbo.Pedidos WHERE ValorTotal - Custo > 100;Y por eso también el ORDER BY sí puede usar el alias: se ejecuta después del SELECT.
Una collation es la regla que define cómo la base compara y ordena texto: si distingue mayúsculas de minúsculas, si ignora los acentos, cuál es el orden alfabético.
Su nombre lo cuenta todo: en SQL_Latin1_General_CP1_CI_AS, el CI es case insensitive (ignora las mayúsculas) y el AS es accent sensitive (distingue los acentos).
-- Con CI: vuelven las dos líneas
SELECT * FROM dbo.Clientes WHERE Nome = 'joão';
SELECT * FROM dbo.Clientes WHERE Nome = 'JOÃO';
-- Ver la collation de cada columna
SELECT name, collation_name FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.Clientes') AND collation_name IS NOT NULL;El problema aparece al unir tablas de bases con collations distintas: el JOIN falla con "Cannot resolve the collation conflict". La salida es forzar una de las dos:
SELECT a.Nome
FROM BancoA.dbo.Clientes a
JOIN BancoB.dbo.Clientes b
ON a.Codigo = b.Codigo COLLATE SQL_Latin1_General_CP1_CI_AS;Una clave primaria (PK) es la columna que identifica la fila de forma única. No acepta nulos y no se repite. Toda tabla debería tener una.
Una clave foránea (FK) es la columna que apunta a la clave primaria de otra tabla. Es lo que impide que exista un pedido de un cliente que no existe.
CREATE TABLE dbo.Clientes (
ClienteID INT IDENTITY PRIMARY KEY, -- clave primaria
Nome VARCHAR(200) NOT NULL
);
CREATE TABLE dbo.Pedidos (
PedidoID INT IDENTITY PRIMARY KEY,
ClienteID INT NOT NULL,
CONSTRAINT FK_Pedidos_Clientes
FOREIGN KEY (ClienteID) REFERENCES dbo.Clientes (ClienteID)
);Con esa FK en su sitio, intentar insertar un pedido para el cliente 9999 (que no existe) da error. Es la base protegiendo el dato de un bug de la aplicación.
IDENTITY y qué es SEQUENCE?IDENTITY hace que la columna se numere sola en cada INSERT. Es la forma más común de generar un id.
CREATE TABLE dbo.Clientes (ClienteID INT IDENTITY(1,1) PRIMARY KEY, Nome VARCHAR(200));
-- ^ empieza en 1, suma 1 por fila
INSERT INTO dbo.Clientes (Nome) VALUES ('Maria');
SELECT SCOPE_IDENTITY(); -- el id que se acaba de generar en esta sesiónUsa SCOPE_IDENTITY() y no @@IDENTITY: el segundo devuelve el id generado por cualquier cosa, incluido un trigger que se ejecutó después — y entonces guardas el id equivocado.
SEQUENCE es un contador independiente de cualquier tabla, útil cuando dos tablas necesitan compartir la misma numeración:
CREATE SEQUENCE dbo.NumeroDocumento AS INT START WITH 1000 INCREMENT BY 1;
SELECT NEXT VALUE FOR dbo.NumeroDocumento;En los dos casos, los huecos en la numeración son normales: una transacción cancelada consume el número y no lo devuelve.
WHERE y HAVING?WHERE filtra filas, antes de agrupar. HAVING filtra grupos, después de agrupar. Esa es la única diferencia, y decide cuál usar.
SELECT ClienteID, SUM(ValorTotal) AS Total
FROM dbo.Pedidos
WHERE DataPedido >= '2026-01-01' -- descarta los pedidos antiguos ANTES de sumar
GROUP BY ClienteID
HAVING SUM(ValorTotal) > 10000; -- descarta los clientes pequeños DESPUÉS de sumarNo se puede cambiar uno por el otro: WHERE SUM(...) > 10000 da error, porque en el momento del WHERE la suma todavía no existe.
JOIN y cuáles son los tipos?Un JOIN une filas de dos tablas usando una condición. Los cuatro que importan:
-- INNER: solo lo que existe en los dos lados
SELECT c.Nome, p.PedidoID
FROM dbo.Clientes c INNER JOIN dbo.Pedidos p ON p.ClienteID = c.ClienteID;
-- LEFT: todos los clientes; sin pedido, las columnas de pedido vienen NULL
SELECT c.Nome, p.PedidoID
FROM dbo.Clientes c LEFT JOIN dbo.Pedidos p ON p.ClienteID = c.ClienteID;
-- FULL: todo de los dos lados
SELECT c.Nome, p.PedidoID
FROM dbo.Clientes c FULL OUTER JOIN dbo.Pedidos p ON p.ClienteID = c.ClienteID;
-- CROSS: cada fila de A con cada fila de B (el producto cartesiano)
SELECT v.Nome, m.Mes FROM dbo.Vendedores v CROSS JOIN dbo.Meses m;RIGHT JOIN existe, pero en la práctica no lo usa nadie: es el LEFT con las tablas cambiadas, y se lee peor.
Usa LEFT JOIN cuando la pregunta sea "todos los X, con los Y que tengan" — por ejemplo, "todos los clientes y cuánto compró cada uno, incluido quien no compró nada".
LEFT JOIN a veces se comporta como un INNER JOIN?Porque la condición de la tabla de la derecha acabó en el WHERE. Como el WHERE se ejecuta después del join, descarta las filas en las que esa columna vino nula — que son exactamente las que el LEFT JOIN había preservado.
-- Se convierte en INNER sin avisar: un cliente sin pedido tiene Status NULL y se descarta
SELECT c.Nome, p.PedidoID
FROM dbo.Clientes c
LEFT JOIN dbo.Pedidos p ON p.ClienteID = c.ClienteID
WHERE p.Status = 'FATURADO';
-- Correcto: la condición de la derecha va en el ON
SELECT c.Nome, p.PedidoID
FROM dbo.Clientes c
LEFT JOIN dbo.Pedidos p
ON p.ClienteID = c.ClienteID
AND p.Status = 'FATURADO';Regla práctica: en un LEFT JOIN, la condición sobre la tabla de la izquierda va en el WHERE; sobre la de la derecha, va en el ON.
UNION y UNION ALL?Los dos apilan el resultado de dos consultas. UNION quita los duplicados; UNION ALL no quita nada.
SELECT Email FROM dbo.Clientes
UNION ALL -- mantiene los repetidos, y es más rápido
SELECT Email FROM dbo.Fornecedores;Quitar los duplicados cuesta una ordenación del resultado entero. Si sabes que no hay repetición — o si la repetición no es un problema — usa siempre UNION ALL. La diferencia en tablas grandes es enorme.
EXISTS en lugar de IN?IN compara con una lista de valores. EXISTS solo comprueba si la subconsulta devuelve alguna fila, y se para en la primera que encuentra.
-- IN: construye la lista entera
SELECT Nome FROM dbo.Clientes
WHERE ClienteID IN (SELECT ClienteID FROM dbo.Pedidos WHERE ValorTotal > 1000);
-- EXISTS: se para en el primer hallazgo
SELECT Nome FROM dbo.Clientes c
WHERE EXISTS (SELECT 1 FROM dbo.Pedidos p
WHERE p.ClienteID = c.ClienteID AND p.ValorTotal > 1000);En rendimiento, el optimizador de SQL Server suele tratarlos de forma parecida. La diferencia seria está en la negación: NOT IN devuelve un resultado vacío si la subconsulta contiene un único NULL, porque "X no está en una lista que contiene lo desconocido" es indeterminado. NOT EXISTS no tiene ese problema. Prefiere siempre NOT EXISTS.
Una CTE (Common Table Expression) es un resultado temporal con nombre, declarado con WITH antes de la consulta. Sirve para partir una consulta grande en pasos legibles, leídos de arriba abajo.
WITH TotalPorCliente AS (
SELECT ClienteID, SUM(ValorTotal) AS Total
FROM dbo.Pedidos
GROUP BY ClienteID
)
SELECT c.Nome, t.Total
FROM TotalPorCliente t
JOIN dbo.Clientes c ON c.ClienteID = t.ClienteID
WHERE t.Total > 5000;Existe solo durante esa consulta. Y no guarda datos: el optimizador expande su texto 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 #tabla suele salir más barata.
Es una CTE que se llama a sí misma, usada para recorrer jerarquías: un organigrama, categorías con subcategorías, la estructura de un producto.
WITH Hierarquia AS (
SELECT FuncionarioID, Nome, GerenteID, 0 AS Nivel
FROM dbo.Funcionarios
WHERE GerenteID IS NULL -- el ancla: por dónde empieza
UNION ALL
SELECT f.FuncionarioID, f.Nome, f.GerenteID, h.Nivel + 1
FROM dbo.Funcionarios f
JOIN Hierarquia h ON h.FuncionarioID = f.GerenteID -- el paso recursivo
)
SELECT REPLICATE(' ', Nivel) + Nome AS Estrutura FROM Hierarquia
OPTION (MAXRECURSION 100);MAXRECURSION es el tope de seguridad. Sin él, un ciclo en los datos (A dirige a B, B dirige a A) se ejecuta para siempre.
Es un cálculo hecho sobre un conjunto de filas sin juntar las filas. Con GROUP BY, diez pedidos se convierten en una fila de total. Con una función de ventana, los diez pedidos siguen ahí, cada uno llevando el total al lado.
La cláusula OVER define la ventana: PARTITION BY dice cómo agrupar, ORDER BY dice en qué orden.
SELECT PedidoID,
ClienteID,
ValorTotal,
SUM(ValorTotal) OVER (PARTITION BY ClienteID) AS TotalDoCliente,
AVG(ValorTotal) OVER (PARTITION BY ClienteID) AS MediaDoCliente,
ValorTotal - LAG(ValorTotal) OVER (PARTITION BY ClienteID
ORDER BY DataPedido) AS DiferencaAnterior
FROM dbo.Pedidos;LAG coge el valor de la fila anterior y LEAD el de la siguiente — los dos resuelven "cuánto varió respecto al pedido pasado" sin necesitar un join de la tabla consigo misma.
ROW_NUMBER, RANK y DENSE_RANK?Las tres numeran filas; la diferencia está en el empate:
| Valor | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 90 | 3 | 3 | 2 |
| 80 | 4 | 4 | 3 |
SELECT Nome, Total,
ROW_NUMBER() OVER (ORDER BY Total DESC) AS Linha,
RANK() OVER (ORDER BY Total DESC) AS Posicao,
DENSE_RANK() OVER (ORDER BY Total DESC) AS PosicaoDensa
FROM dbo.ResumoVendedores;Usa ROW_NUMBER cuando necesites un número único (para deduplicar, por ejemplo) y DENSE_RANK cuando quieras "los tres valores más altos", contando los empates como una sola posición.
Dos formas buenas. La primera, con ROW_NUMBER:
WITH Numerados AS (
SELECT p.*,
ROW_NUMBER() OVER (PARTITION BY ClienteID ORDER BY DataPedido DESC) AS rn
FROM dbo.Pedidos p
)
SELECT * FROM Numerados WHERE rn = 1;La segunda, con OUTER APPLY — que suele ser más rápida cuando hay índice, porque busca una fila por cliente en lugar de numerarlo todo:
SELECT c.Nome, ult.PedidoID, ult.DataPedido
FROM dbo.Clientes c
OUTER APPLY (
SELECT TOP (1) p.PedidoID, p.DataPedido
FROM dbo.Pedidos p
WHERE p.ClienteID = c.ClienteID
ORDER BY p.DataPedido DESC
) ult;CROSS APPLY?Es un JOIN en el que el lado derecho ve cada fila del lado izquierdo. Un JOIN normal no puede hacer eso — la subconsulta de la derecha es independiente.
CROSS APPLY descarta la fila de la izquierda cuando el lado derecho no devuelve nada (como INNER JOIN); OUTER APPLY la mantiene, rellenando con nulos (como LEFT JOIN).
Además del "último de cada grupo", sirve para llamar a una función con el valor de la fila:
SELECT c.Nome, itens.ProdutoID, itens.Quantidade
FROM dbo.Clientes c
CROSS APPLY dbo.tvf_ItensDoCliente(c.ClienteID) AS itens;Con OFFSET y FETCH NEXT, que exigen un ORDER BY:
DECLARE @Pagina INT = 3, @Tamanho INT = 20;
SELECT PedidoID, DataPedido, ValorTotal
FROM dbo.Pedidos
ORDER BY DataPedido DESC
OFFSET (@Pagina - 1) * @Tamanho ROWS
FETCH NEXT @Tamanho ROWS ONLY;Un aviso: un OFFSET grande es lento, porque la base necesita recorrer y descartar todas las filas saltadas. La página 5.000 cuesta mucho más que la página 2. En listas realmente grandes, el mejor patrón es la paginación por clave — en lugar de "sáltate 100.000", pide "los 20 siguientes al último que vi":
SELECT TOP (20) PedidoID, DataPedido
FROM dbo.Pedidos
WHERE DataPedido < @UltimaDataVista
ORDER BY DataPedido DESC;DELETE, TRUNCATE y DROP?| Comando | Qué hace | Acepta WHERE | Reinicia el IDENTITY | Velocidad |
|---|---|---|---|---|
DELETE | Borra filas | Sí | No | Lento (registra fila a fila) |
TRUNCATE | Vacía la tabla | No | Sí, hasta el inicio | Muy rápido |
DROP | Borra la tabla entera | No | — | Rápido |
DELETE FROM dbo.Pedidos WHERE DataPedido < '2020-01-01'; -- borra una parte
TRUNCATE TABLE stg.Pedidos; -- vacía la staging
DROP TABLE stg.PedidosAntigo; -- la tabla desapareceTRUNCATE es el correcto para limpiar una tabla de staging antes de cada carga. Pero no funciona si la tabla está referenciada por una clave foránea, y no dispara triggers.
Y los dos primeros se pueden deshacer si están dentro de una transacción no confirmada — incluido el TRUNCATE, al contrario de lo que mucha gente cree.
Una transacción es un conjunto de sentencias que vale todo o nada. Si una falla, todas vuelven atrás.
BEGIN TRANSACTION;
UPDATE dbo.Conta SET Saldo = Saldo - 100 WHERE ContaID = 1;
UPDATE dbo.Conta SET Saldo = Saldo + 100 WHERE ContaID = 2;
COMMIT; -- o ROLLBACK para deshacerloSin transacción, un fallo entre las dos sentencias haría desaparecer el dinero. ACID son las cuatro garantías que da la base:
COMMIT, el dato sobrevive a un corte de luz.Es una consulta guardada con nombre, que usas como si fuera una tabla.
CREATE OR ALTER VIEW dbo.vw_PedidosFaturados AS
SELECT p.PedidoID, p.ClienteID, c.Nome AS ClienteNome, p.ValorTotal
FROM dbo.Pedidos p
JOIN dbo.Clientes c ON c.ClienteID = p.ClienteID
WHERE p.Status = 'FATURADO';
GO
SELECT * FROM dbo.vw_PedidosFaturados WHERE ValorTotal > 1000;Sirve para tres cosas: esconder complejidad (el join se escribe una vez), dar seguridad (la persona accede a la vista sin acceder a la tabla, y tú omites las columnas sensibles) y crear un contrato estable (si la tabla cambia, ajustas la vista y quien la consume no se entera).
No. La vista guarda solo el texto de la consulta. Cada vez que la consultas, la consulta que hay detrás se ejecuta de nuevo, sobre el dato actual.
La excepción es la indexed view, que veremos a continuación.
INSERT o un UPDATE a través de una Vista?Sí, siempre que la vista sea simple: una sola tabla, sin agregación, sin DISTINCT, sin GROUP BY, y con las columnas obligatorias de la tabla presentes en ella.
CREATE OR ALTER VIEW dbo.vw_ClientesSP AS
SELECT ClienteID, Nome, UF FROM dbo.Clientes WHERE UF = 'SP';
GO
UPDATE dbo.vw_ClientesSP SET Nome = 'Maria Silva' WHERE ClienteID = 10;Un detalle traicionero: sin WITH CHECK OPTION, puedes actualizar una fila para que salga de la vista — cambiar el estado a 'RJ' hace que el registro desaparezca, y la sentencia se acepta. Con la opción, la base lo rechaza:
CREATE OR ALTER VIEW dbo.vw_ClientesSP AS
SELECT ClienteID, Nome, UF FROM dbo.Clientes WHERE UF = 'SP'
WITH CHECK OPTION;Es la excepción de la regla: una vista con un índice clusterizado único, cuyo resultado se graba en disco y SQL Server mantiene actualizado automáticamente. Sirve para una agregación pesada que se consulta todo el tiempo.
CREATE OR ALTER VIEW dbo.vw_VendasPorDia
WITH SCHEMABINDING AS -- obligatorio: ata la vista a las tablas
SELECT CAST(DataPedido AS date) AS Dia,
COUNT_BIG(*) AS Pedidos, -- COUNT_BIG, no COUNT
SUM(ValorTotal) AS Total
FROM dbo.Pedidos -- esquema obligatorio
WHERE Status = 'FATURADO'
GROUP BY CAST(DataPedido AS date);
GO
CREATE UNIQUE CLUSTERED INDEX IX_vw_VendasPorDia ON dbo.vw_VendasPorDia (Dia);El precio es que cada INSERT/UPDATE en la tabla base se vuelve un poco más lento, porque tiene que actualizar la vista también. Compensa cuando la lectura es mucho más frecuente que la escritura.
Técnicamente muchas; en la práctica, dos. Una vista sobre una vista sobre una vista parece organización, pero el optimizador tiene que expandirlo todo y acaba leyendo tablas que nadie pidió en esa consulta. Cuando ya no puedes decir de memoria qué tablas lee una vista, el rendimiento ya se fue.
Es un bloque de T-SQL guardado en la base, con nombre y parámetros, que ejecutas cuando quieres.
CREATE OR ALTER PROCEDURE dbo.usp_PedidosPorCliente
@ClienteID INT
AS
BEGIN
SET NOCOUNT ON;
SELECT PedidoID, DataPedido, ValorTotal
FROM dbo.Pedidos
WHERE ClienteID = @ClienteID
ORDER BY DataPedido DESC;
END
GO
EXEC dbo.usp_PedidosPorCliente @ClienteID = 42;Merece la pena usarlas por cuatro motivos: el plan de ejecución queda en caché y se reaprovecha; la regla vive en un solo sitio, en vez de repetida en cada pantalla; das permiso para ejecutar el procedimiento sin dar acceso a las tablas; y la red transporta una llamada corta en lugar de una consulta entera.
Tres caminos, con propósitos distintos:
CREATE OR ALTER PROCEDURE dbo.usp_ContaPedidos
@ClienteID INT,
@Total INT OUTPUT -- 1) un parámetro de salida
AS
BEGIN
SET NOCOUNT ON;
SELECT @Total = COUNT(*) FROM dbo.Pedidos WHERE ClienteID = @ClienteID;
RETURN 0; -- 2) un código de retorno: solo INT, úsalo para el estado
END
GO
DECLARE @Qtd INT;
EXEC dbo.usp_ContaPedidos @ClienteID = 42, @Total = @Qtd OUTPUT;
SELECT @Qtd;El tercer camino es simplemente hacer un SELECT dentro del procedimiento — el resultado vuelve como un conjunto de filas. Es el más común cuando el procedimiento alimenta un informe.
Usa RETURN solo para el estado (0 = ok, 1 = error); solo acepta enteros.
SET NOCOUNT ON?Por defecto, cada sentencia devuelve un mensaje "(N rows affected)". Eso gasta red y, peor, algunos clientes — incluido el componente de SSIS — interpretan ese mensaje como un resultado más, lo que rompe la lectura.
SET NOCOUNT ON desactiva esos mensajes. Ponlo como primera línea de todo procedimiento. No cambia nada en el resultado, solo quita el ruido.
Con TRY...CATCH, y siempre junto a una transacción cuando hay escritura:
CREATE OR ALTER PROCEDURE dbo.usp_FaturarPedido
@PedidoID INT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- garantiza el rollback en un error que no abortaría solo
BEGIN TRY
BEGIN TRANSACTION;
IF NOT EXISTS (SELECT 1 FROM dbo.Pedidos WHERE PedidoID = @PedidoID)
THROW 50001, 'Pedido não encontrado.', 1;
UPDATE dbo.Pedidos SET Status = 'FATURADO' WHERE PedidoID = @PedidoID;
COMMIT;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK;
INSERT INTO dbo.LogErro (Rotina, Mensagem, Numero, Linha)
VALUES ('usp_FaturarPedido', ERROR_MESSAGE(), ERROR_NUMBER(), ERROR_LINE());
THROW; -- relanza el error original a quien llamó
END CATCH
END
GOTHROW sin argumentos relanza el error preservando su número y su mensaje. XACT_STATE() distingue una transacción todavía utilizable de una ya condenada — comprobar solo @@TRANCOUNT no cubre ese caso.
Es que SQL Server compile el plan del procedimiento a partir del primer valor de parámetro que recibió, y reutilice ese plan para todos los valores siguientes.
Funciona bien cuando los valores son parecidos. Se rompe cuando no lo son: si la primera llamada fue para un cliente con 3 pedidos, el plan elegido lee fila a fila; cuando llega un cliente con 2 millones de pedidos, se usa ese mismo plan y la consulta se atasca.
Es la explicación más común de "el procedimiento se puso lento y nadie tocó nada". La salida más directa:
SELECT PedidoID, ValorTotal
FROM dbo.Pedidos
WHERE ClienteID = @ClienteID
OPTION (RECOMPILE); -- recompila solo esta sentencia, en cada ejecuciónAlternativas: OPTION (OPTIMIZE FOR UNKNOWN), que usa la media de las estadísticas, o copiar el parámetro a una variable local.
| Procedure | Function | |
|---|---|---|
| Modifica datos | Sí | No |
Se usa dentro de un SELECT | No | Sí |
| Devuelve | Conjuntos, OUTPUT, un código | Un valor o una tabla |
| Transacción | Puede controlarla | No puede |
TRY...CATCH | Puede | No puede |
Una regla sencilla: si hace algo, es un procedimiento. Si calcula algo para usarlo en una consulta, es una función.
CREATE TABLE #Temporaria (ID INT, Nome VARCHAR(100)); -- tabla temporal
DECLARE @Variavel TABLE (ID INT, Nome VARCHAR(100)); -- variable de tabla#tabla | @variable | |
|---|---|---|
| Estadísticas | Tiene | No tiene |
| Un índice tras crearla | Puede | No puede |
| Vive hasta | El fin de la sesión | El fin del lote |
| Participa en una transacción | Sí | No (no sufre rollback) |
El punto que decide: las estadísticas. Sin ellas, el optimizador estima un número fijo de filas para la variable de tabla y elige planes malos cuando el volumen es grande. Para pocas filas, @variable es más ligera. Para muchas, usa #tabla.
Un cursor es la construcción que recorre un resultado fila a fila, como un bucle de programación.
DECLARE @ID INT;
DECLARE c CURSOR FOR SELECT PedidoID FROM dbo.Pedidos WHERE Status = 'ABERTO';
OPEN c;
FETCH NEXT FROM c INTO @ID;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC dbo.usp_FaturarPedido @PedidoID = @ID;
FETCH NEXT FROM c INTO @ID;
END
CLOSE c; DEALLOCATE c;Las bases relacionales están hechas para trabajar con conjuntos, no con bucles. El mismo trabajo en un único UPDATE suele ser decenas de veces más rápido:
UPDATE dbo.Pedidos SET Status = 'FATURADO' WHERE Status = 'ABERTO';Un cursor se justifica cuando cada fila necesita de verdad un tratamiento distinto e indivisible — llamar a un servicio externo, por ejemplo. Fuera de eso, casi siempre existe una versión en conjunto.
La SQL injection es cuando el texto que escribe el usuario se convierte en comando, en lugar de quedarse como dato. Ocurre siempre que la consulta se monta por concatenación:
-- VULNERABLE: si @Nome es ' OR 1=1 -- la consulta devuelve todos los clientes
DECLARE @Sql NVARCHAR(500) = 'SELECT * FROM dbo.Clientes WHERE Nome = ''' + @Nome + '''';
EXEC (@Sql);Con un parámetro, el valor viaja separado del texto del comando y nunca se interpreta como código:
-- SEGURO
SELECT * FROM dbo.Clientes WHERE Nome = @Nome;Por eso un procedimiento con parámetros es seguro por construcción — siempre que no concatene el parámetro dentro de SQL dinámico.
Usa sp_executesql, que acepta parámetros de verdad:
DECLARE @Sql NVARCHAR(MAX) = N'SELECT PedidoID, ValorTotal FROM dbo.Pedidos WHERE 1 = 1';
IF @ClienteID IS NOT NULL
SET @Sql += N' AND ClienteID = @ClienteID';
IF @DataInicio IS NOT NULL
SET @Sql += N' AND DataPedido >= @DataInicio';
EXEC sp_executesql @Sql,
N'@ClienteID INT, @DataInicio DATE', -- la declaración de los parámetros
@ClienteID = @ClienteID, -- los valores
@DataInicio = @DataInicio;Fíjate en que el valor nunca entra en la cadena — solo el nombre del parámetro. Y si necesitas montar un nombre de tabla o de columna dinámicamente (que no puede ser un parámetro), pásalo por QUOTENAME(), que escapa los corchetes y cierra la puerta a la inyección.
Tres, y la elección cambia drásticamente el rendimiento:
Escalar — devuelve un valor único:
CREATE OR ALTER FUNCTION dbo.fn_Desconto (@Valor DECIMAL(18,2))
RETURNS DECIMAL(18,2)
AS
BEGIN
RETURN CASE WHEN @Valor > 1000 THEN @Valor * 0.10 ELSE 0 END;
ENDInline table-valued (iTVF) — devuelve una tabla, con un único RETURN:
CREATE OR ALTER FUNCTION dbo.tvf_PedidosDoCliente (@ClienteID INT)
RETURNS TABLE
AS
RETURN (SELECT PedidoID, DataPedido, ValorTotal
FROM dbo.Pedidos WHERE ClienteID = @ClienteID);Multi-statement table-valued (MSTVF) — devuelve una tabla montada en varios pasos:
CREATE OR ALTER FUNCTION dbo.mstvf_Resumo (@Ano INT)
RETURNS @R TABLE (Mes INT, Total DECIMAL(18,2))
AS
BEGIN
INSERT INTO @R SELECT MONTH(DataPedido), SUM(ValorTotal)
FROM dbo.Pedidos WHERE YEAR(DataPedido) = @Ano GROUP BY MONTH(DataPedido);
RETURN;
ENDPorque se ejecuta una vez por fila. En una consulta sobre un millón de filas, la función se ejecuta un millón de veces, y cada ejecución es un cambio de contexto dentro del motor.
Peor: hasta SQL Server 2017, la presencia de una escalar impedía el paralelismo en toda la consulta — incluso en las partes que nada tenían que ver con ella.
SQL Server 2019 introdujo el inlining automático, que reescribe algunas escalares como expresión y resuelve el problema. Pero solo algunas (nada de acceso a tablas, nada de WHILE), y no siempre controlas la versión del servidor.
La salida es transformar la escalar en una iTVF y llamarla con APPLY:
CREATE OR ALTER FUNCTION dbo.tvf_Desconto (@Valor DECIMAL(18,2))
RETURNS TABLE
AS
RETURN (SELECT CASE WHEN @Valor > 1000 THEN @Valor * 0.10 ELSE 0 END AS Desconto);
GO
SELECT p.PedidoID, d.Desconto
FROM dbo.Pedidos p
CROSS APPLY dbo.tvf_Desconto(p.ValorTotal) d;La ausencia de BEGIN/END. Una iTVF tiene un único RETURN (consulta) y nada más. Es esa forma la que permite al optimizador expandir la función dentro de la consulta que la llama, como si fuera una vista con parámetro — el coste se vuelve prácticamente cero.
La misma lógica escrita con BEGIN ... RETURN ... END se convierte en multi-statement, y el optimizador pasa a tratarla como una caja negra: no ve el contenido y estima un número fijo de filas (1 hasta SQL Server 2012, 100 después), lo que produce planes malos.
Regla práctica: si cabe en una consulta, hazla iTVF. Si necesita varios pasos, haz un procedimiento que escriba en una #tabla.
No. Ningún tipo de function puede hacer un INSERT, UPDATE o DELETE en una tabla permanente, ni llamar a un procedimiento que modifique datos, ni controlar una transacción.
Es deliberado: como la function se usa dentro de un SELECT, la base necesita poder ejecutarla tantas veces como quiera, en cualquier orden, sin efectos colaterales. Si necesitas modificar datos, usa un procedimiento.
Es una estructura auxiliar que permite encontrar filas sin leer la tabla entera. Internamente es un árbol B (B-tree): una raíz que apunta a páginas intermedias, que apuntan a las hojas con los datos ordenados.
Para encontrar un valor entre un millón de filas, en lugar de mirar un millón de veces, la base baja unos 3 o 4 niveles del árbol. Es la misma idea que el índice del final de un libro: no lees el libro entero buscando la palabra.
CREATE NONCLUSTERED INDEX IX_Pedidos_Cliente ON dbo.Pedidos (ClienteID);El precio: cada INSERT, UPDATE y DELETE tiene que mantener el índice actualizado. Demasiados índices hacen lenta la escritura; muy pocos, la lectura.
El clustered define el orden físico de las filas en la tabla — los datos son las hojas del índice. Por eso solo puede haber uno por tabla. Cuando creas una clave primaria, SQL Server crea un clustered sobre ella por defecto.
El nonclustered es una estructura aparte, que guarda las columnas indexadas y un puntero a la fila. Puede haber varios.
La analogía que funciona: el clustered es el orden de los capítulos del libro; el nonclustered es el índice del final, que dice en qué página está cada tema.
INCLUDE en un índice y qué es un key lookup?Cuando el índice encuentra la fila pero no tiene todas las columnas pedidas, la base tiene que volver a la tabla a buscar el resto. Esa vuelta es el key lookup, y cuesta caro cuando se repite miles de veces.
INCLUDE lo resuelve: las columnas incluidas quedan guardadas en las hojas del índice, sin formar parte de la clave de búsqueda.
CREATE NONCLUSTERED INDEX IX_Pedidos_Cliente_Data
ON dbo.Pedidos (ClienteID, DataPedido) -- clave: usada para filtrar y ordenar
INCLUDE (ValorTotal, Status); -- de acompañantes: solo se devuelvenCuando el índice tiene todo lo que la consulta necesita, decimos que la cubre. El key lookup desaparece del plan.
El 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.
SARGable viene de Search ARGument able: quiere decir que el filtro puede aprovechar el índice. Deja de serlo cuando aplicas una función o un cálculo a la columna.
-- No SARGable: necesita calcular YEAR() en cada fila
WHERE YEAR(DataPedido) = 2026
-- SARGable: se usa el índice de DataPedido
WHERE DataPedido >= '2026-01-01' AND DataPedido < '2027-01-01'Otros casos comunes: WHERE UPPER(Nome) = 'MARIA' (la collation ya lo resuelve), WHERE Codigo + '' = '123' y WHERE ISNULL(Valor, 0) > 100. En todos, el arreglo es el mismo — deja la columna sola en un lado de la comparación.
Fíjate también en el intervalo con < al final, en lugar de BETWEEN '2026-01-01' AND '2026-12-31'. Con fecha y hora, el BETWEEN pierde todo lo que ocurrió después de la medianoche del último día.
Es el paso a paso que SQL Server decidió seguir para responder a la consulta. En SSMS, Ctrl+M activa el plan real y Ctrl+L, el estimado.
Qué buscar, en orden de importancia:
INCLUDE.Para medir sin depender del reloj de la máquina:
SET STATISTICS IO, TIME ON;
-- tu consulta
SET STATISTICS IO, TIME OFF;STATISTICS IO muestra las lecturas lógicas por tabla. Es el número más honesto: el tiempo varía con la carga del servidor, las lecturas no. ¿Bajó de 400.000 a 300? Entonces mejoró de verdad.
Son los histogramas que SQL Server mantiene sobre la distribución de los valores de cada columna indexada. Los usa para estimar cuántas filas devolverá un filtro y, a partir de la estimación, elegir el plan.
Una estadística desactualizada genera una estimación errónea, que genera un plan erróneo. Es lo que ocurre después de una carga grande: la base cree que la tabla tiene mil filas y tiene diez millones.
UPDATE STATISTICS dbo.Pedidos WITH FULLSCAN; -- una tabla
EXEC sp_updatestats; -- la base enteraEjecutar UPDATE STATISTICS al final de una carga grande es una de las acciones de mayor efecto por menor esfuerzo.
Con el tiempo, las inserciones y las eliminaciones dejan las páginas del índice fuera de orden y con espacio vacío. La base pasa a leer más páginas para el mismo dato.
-- Ver cuánto está fragmentado
SELECT OBJECT_NAME(ips.object_id) AS Tabela,
i.name AS Indice,
ips.avg_fragmentation_in_percent AS Fragmentacao
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i ON i.object_id = ips.object_id AND i.index_id = ips.index_id
WHERE ips.avg_fragmentation_in_percent > 10
ORDER BY ips.avg_fragmentation_in_percent DESC;La regla habitual: hasta el 5%, ignóralo; entre el 5% y el 30%, REORGANIZE; por encima del 30%, REBUILD.
ALTER INDEX IX_Pedidos_Cliente ON dbo.Pedidos REORGANIZE;
ALTER INDEX IX_Pedidos_Cliente ON dbo.Pedidos REBUILD;REORGANIZE es ligero y online. REBUILD es más completo, y bloquea la tabla en la edición Standard — déjalo para una ventana de mantenimiento.
Con las DMV (Dynamic Management Views), que exponen lo que está haciendo el motor:
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 qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY qs.total_worker_time DESC;Ordena por total_worker_time para encontrar quién consume CPU y por total_logical_reads para encontrar quién lee demasiado. Una consulta rápida que se ejecuta 100 mil veces por hora suele pesar más que una lenta que se ejecuta una vez.
En SQL Server 2016 o más nuevo, activa el Query Store en la base: guarda el histórico de planes y permite forzar el plan bueno cuando uno empeora.
Agrupa por la columna que debería ser única y cuenta:
SELECT CPF, COUNT(*) AS Qtd
FROM dbo.Clientes
WHERE CPF IS NOT NULL
GROUP BY CPF
HAVING COUNT(*) > 1
ORDER BY Qtd DESC;Para borrarlos manteniendo el más reciente, ROW_NUMBER es el camino seguro — 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 ejecutarlo en producción: cambia el DELETE por un SELECT *, comprueba el recuento y haz una copia de seguridad.
Un huérfano es un hijo sin padre — el pedido cuyo cliente ya no existe:
SELECT p.PedidoID, p.ClienteID
FROM dbo.Pedidos p
WHERE NOT EXISTS (SELECT 1 FROM dbo.Clientes c WHERE c.ClienteID = p.ClienteID);Si la clave foránea existiera y estuviera activa, esto no ocurriría. Un huérfano es señal de una FK que falta, o de una FK que se deshabilitó durante una carga y nunca se revalidó.
Compara los dos lados al mismo nivel de detalle y mira la diferencia, en lugar de mirar solo los totales:
-- La cabecera contra la suma de las líneas
SELECT p.PedidoID,
p.ValorTotal AS TotalCabecalho,
SUM(i.Quantidade * i.ValorUnit) AS TotalItens,
p.ValorTotal - SUM(i.Quantidade * i.ValorUnit) AS Diferenca
FROM dbo.Pedidos p
JOIN dbo.PedidoItens i ON i.PedidoID = p.PedidoID
GROUP BY p.PedidoID, p.ValorTotal
HAVING ABS(p.ValorTotal - SUM(i.Quantidade * i.ValorUnit)) > 0.01;Las causas más frecuentes, en orden:
INNER JOIN donde debería haber un LEFT — los registros desaparecen sin aviso.BETWEEN — pierde el último día.NULL en una suma — SUM ignora los nulos, + los propaga.El > 0.01 en lugar de <> 0 no es pereza: con el redondeo de la moneda, aparecen diferencias de céntimo por la representación numérica.
JOIN. ¿Cómo los encuentro?'ABC ' y 'ABC' parecen iguales en pantalla. LEN() ignora los espacios finales, DATALENGTH() no — cuando los dos discrepan, lo has encontrado:
SELECT ClienteID,
'[' + Codigo + ']' AS ComDelimitador,
LEN(Codigo) AS Tamanho,
DATALENGTH(Codigo) AS Bytes
FROM dbo.Clientes
WHERE Codigo <> LTRIM(RTRIM(Codigo));En SQL Server 2017 o más nuevo, TRIM() limpia los dos lados de una vez. Para las mayúsculas, una collation CI ya lo resuelve — si la tuya es CS, normaliza los dos lados con UPPER().
DBCC CHECKDB y qué es una FK "no fiable"?DBCC CHECKDB verifica la integridad física de la base: páginas corruptas, punteros rotos, índices inconsistentes con los datos. Es pesado — ejecútalo en una ventana de mantenimiento.
DBCC CHECKDB ('VendasDB') WITH NO_INFOMSGS, ALL_ERRORMSGS;Una FK no fiable, en cambio, es un problema lógico. Ocurre después de un BULK INSERT o de un ALTER TABLE ... NOCHECK CONSTRAINT: la restricción sigue ahí, pero SQL Server sabe que entraron datos sin pasar por ella.
SELECT name, is_not_trusted FROM sys.foreign_keys WHERE is_not_trusted = 1;Dos consecuencias: deja de garantizar lo que promete, y el optimizador deja de usarla para simplificar planes. 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.
Tres tipos, que se combinan:
-- Completo: todo, la base de cualquier restauración
BACKUP DATABASE VendasDB TO DISK = 'D:\bkp\VendasDB_full.bak' WITH INIT, COMPRESSION;
-- Diferencial: solo lo que cambió desde el último completo
BACKUP DATABASE VendasDB TO DISK = 'D:\bkp\VendasDB_diff.bak' WITH DIFFERENTIAL;
-- Log: las transacciones desde el último backup de log (exige el recovery model FULL)
BACKUP LOG VendasDB TO DISK = 'D:\bkp\VendasDB_log.trn';Para restaurar hasta un punto en el tiempo, aplícalos en orden: completo → el diferencial más reciente → todos los logs siguientes. Usa NORECOVERY en todos menos en el último.
RESTORE DATABASE VendasDB FROM DISK = 'D:\bkp\VendasDB_full.bak' WITH NORECOVERY;
RESTORE DATABASE VendasDB FROM DISK = 'D:\bkp\VendasDB_diff.bak' WITH NORECOVERY;
RESTORE LOG VendasDB FROM DISK = 'D:\bkp\VendasDB_log.trn'
WITH STOPAT = '2026-03-19T14:30:00', RECOVERY;Una verdad incómoda: un backup que nunca se ha restaurado no es un backup. Prueba la restauración periódicamente, en un servidor aparte.
ETL significa Extract, Transform, Load — extraer de un origen, transformarlo al formato que necesitas y cargarlo en un destino. Es el proceso que lleva el dato desde el sistema de origen hasta donde se va a analizar.
SSIS (SQL Server Integration Services) es la herramienta de ETL de Microsoft. Diseñas el flujo en Visual Studio (con la extensión Integration Services Projects), generas un paquete y se ejecuta en el servidor, normalmente programado por el SQL Server Agent.
Un paquete típico hace esto: lee un archivo o una tabla de otro sistema, limpia y convierte los datos, y los graba en una tabla de tu base — registrando lo que salió bien y lo que no.
Son las dos superficies de un paquete, y confundirlas es el error más común:
La regla que más tiempo ahorra: lo que se pueda hacer en SQL, hazlo en SQL. Una transformación Sort de SSIS carga todo en la memoria del servidor de integración; un ORDER BY en el origen usa el índice de la base. Deja 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.
La staging es un área intermedia: tablas donde el dato en bruto se vuelca sin ninguna transformación, antes de convertirse en el dato final.
Merece la pena por tres motivos: si algo sale mal, tienes el material original para investigar; transformar con T-SQL sobre la staging es más rápido y más fácil de probar que hacerlo en el componente gráfico; y el origen queda libre enseguida, en lugar de quedar retenido durante todo el procesamiento.
El patrón que resuelve casi toda carga:
1. TRUNCATE de la staging
2. Extraer del origen a la staging (sin transformar)
3. Transformar con T-SQL dentro de la staging
4. Cargar de la staging al destino, dentro de una transacción
5. Validar y registrar el resultadoUna carga full lo borra todo y lo recarga. Sencilla y siempre correcta, pero inviable cuando la tabla tiene millones de filas.
Una carga incremental trae solo lo que cambió desde la última ejecución. Para saber qué cambió, guardas una marca de agua (watermark): la fecha/hora hasta donde ya has cargado.
CREATE TABLE dbo.ControleCarga (
Tabela SYSNAME NOT NULL PRIMARY KEY,
UltimaCarga DATETIME2(3) NOT NULL
);
-- En el origen, el paquete lee solo lo que cambió
DECLARE @Desde DATETIME2(3) =
(SELECT UltimaCarga FROM dbo.ControleCarga WHERE Tabela = 'Pedidos');
SELECT PedidoID, ClienteID, ValorTotal, AlteradoEm
FROM dbo.Pedidos
WHERE AlteradoEm > @Desde;Un detalle que evita perder registros: graba como nueva marca de agua la hora de inicio de la carga, no la del final. Lo que se modifique en el origen mientras el paquete se ejecuta caería en un hueco ciego si usaras la hora final.
Todo componente de 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:
CREATE TABLE stg.LinhasRejeitadas (
RejeitadaID INT IDENTITY PRIMARY KEY,
Pacote SYSNAME NOT NULL,
LinhaOriginal NVARCHAR(MAX) NOT NULL,
ErroCodigo 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.
Después de la carga, valida lo que entró:
SELECT 'Pedidos sem cliente' AS Verificacao, COUNT(*) AS Qtd
FROM stg.Pedidos s
WHERE NOT EXISTS (SELECT 1 FROM dbo.Clientes c WHERE c.ClienteID = s.ClienteID)
UNION ALL
SELECT 'Valor negativo', COUNT(*) FROM stg.Pedidos WHERE ValorTotal < 0
UNION ALL
SELECT 'Data no futuro', COUNT(*) FROM stg.Pedidos WHERE DataPedido > SYSDATETIME();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".
SSRS (SQL Server Reporting Services) es la herramienta de informes de Microsoft. Diseñas en el Report Builder o en Visual Studio y publicas en un portal web, desde donde la gente ejecuta el informe o lo recibe por correo.
Un informe paginado es un informe con maquetación fija, pensado para caber en páginas — para imprimir o convertir en PDF. Una factura, un extracto, un informe contable de 300 páginas con la cabecera repitiéndose en cada una. De ahí viene el nombre: el contenido se organiza en páginas, no en una pantalla que se desplaza.
El archivo generado es un RDL (Report Definition Language), un XML que describe la consulta, los parámetros y la maquetación.
Resuelven problemas distintos:
| SSRS | Power BI | |
|---|---|---|
| Formato | Página fija, hecha para imprimir | Pantalla interactiva |
| Uso | Leer y archivar | Explorar y filtrar |
| Entrega | PDF/Excel por correo programado | Un portal o una app |
| Ejemplo | Una factura, un cierre contable | Un panel de ventas |
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", es SSRS. Si es "quiero entender por qué cayó el margen en el Sur", es Power BI. Confundir los dos genera meses de retrabajo.
CREATE OR ALTER PROCEDURE rpt.usp_VendasPorPeriodo
@DataInicio DATE,
@DataFim DATE,
@VendedorID INT = NULL -- NULL significa "todos"
AS
BEGIN
SET NOCOUNT ON;
SELECT v.Nome AS Vendedor, c.Nome AS Cliente, p.PedidoID, p.ValorTotal
FROM dbo.Pedidos p
JOIN dbo.Clientes c ON c.ClienteID = p.ClienteID
JOIN dbo.Vendedores v ON v.VendedorID = p.VendedorID
WHERE p.DataPedido >= @DataInicio
AND p.DataPedido < DATEADD(DAY, 1, @DataFim) -- incluye el día entero
AND (@VendedorID IS NULL OR p.VendedorID = @VendedorID)
ORDER BY v.Nome, p.DataPedido;
ENDEl 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.
Un parámetro en cascada es cuando uno depende del otro — elegir la provincia filtra las ciudades. Creas un dataset para cada lista, y el segundo recibe el valor del primero:
-- Dataset "Estados"
SELECT DISTINCT UF FROM dbo.Clientes ORDER BY UF;
-- Dataset "Cidades", que recibe @UF
SELECT DISTINCT Cidade FROM dbo.Clientes WHERE UF = @UF ORDER BY Cidade;Para un parámetro de selección múltiple, SSRS entrega una lista separada por comas; del lado del T-SQL, usa STRING_SPLIT:
WHERE c.UF IN (SELECT value FROM STRING_SPLIT(@Ufs, ','));Una subscription es la programación: el informe se ejecuta solo a la hora marcada y entrega el resultado por correo o en una carpeta de red. Es lo que atiende el "cada día 1 a las 6 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 filtrado por su región.
Sobre los componentes de maquetación:
Las expresiones de SSRS usan sintaxis Visual Basic y empiezan con =:
=Sum(Fields!ValorTotal.Value)
=Format(Fields!ValorTotal.Value, "C2")
=IIf(RowNumber(Nothing) Mod 2 = 0, "#F5F5F5", "White")
="Página " & Globals!PageNumber & " de " & Globals!TotalPagesCuidado con el IIf: a diferencia del if de un lenguaje normal, evalúa los dos lados antes de elegir. En una división, eso significa que el error de división por cero ocurre incluso cuando la condición dice que no hay que dividir — protege el divisor, no solo la condición.
BI (Business Intelligence) es el conjunto de prácticas que convierte el dato en bruto en información para decidir: recoger, organizar, analizar y presentar.
Un Data Warehouse es la base hecha para eso. Existe separada de la base de producción por tres razones:
Esa diferencia tiene nombre: OLTP (Online Transaction Processing) es el sistema del día a día, con muchas escrituras pequeñas. OLAP (Online Analytical Processing) es el analítico, con pocas lecturas enormes.
En el modelado dimensional, el diseño estándar es el star schema: una tabla de hechos en el centro, rodeada de dimensiones.
CREATE TABLE dim.Cliente (
ClienteSK INT IDENTITY PRIMARY KEY, -- surrogate key: la clave propia del DW
ClienteBK INT NOT NULL, -- business key: el id del sistema de origen
Nome VARCHAR(200) NOT NULL,
Segmento VARCHAR(50) NULL,
ValidoDe DATE NOT NULL,
ValidoAte DATE NULL, -- NULL = la versión vigente
Corrente BIT NOT NULL DEFAULT 1
);
CREATE TABLE fato.Vendas (
VendaSK BIGINT IDENTITY PRIMARY KEY,
DataSK INT NOT NULL,
ClienteSK INT NOT NULL,
ProdutoSK INT NOT NULL,
Quantidade INT NOT NULL,
ValorBruto DECIMAL(18,2) NOT NULL
);La surrogate key existe para que el DW no dependa del id del origen, que puede cambiar, repetirse entre empresas o desaparecer en una migración.
La granularidad de la tabla de hechos es lo que representa cada fila — ¿una venta? ¿una línea de venta? ¿un día por producto? Definir eso antes de crear la tabla es la decisión más importante del modelo, porque cambiarlo después significa rehacerlo todo.
SCD es Slowly Changing Dimension: una dimensión que cambia despacio. El tipo dice qué hacer cuando un atributo cambia.
El tipo 2 es lo que garantiza que la facturación del año pasado siga sumando en el segmento en el que el cliente estaba en aquel momento, y no en el actual:
-- 1) Cierra la versión vigente de lo que cambió
UPDATE d
SET d.ValidoAte = CAST(SYSDATETIME() AS DATE), d.Corrente = 0
FROM dim.Cliente d
JOIN stg.Cliente s ON s.ClienteID = d.ClienteBK
WHERE d.Corrente = 1 AND d.Segmento <> s.Segmento;
-- 2) Abre la nueva versión
INSERT INTO dim.Cliente (ClienteBK, Nome, Segmento, ValidoDe, Corrente)
SELECT s.ClienteID, s.Nome, s.Segmento, CAST(SYSDATETIME() AS DATE), 1
FROM stg.Cliente s
WHERE NOT EXISTS (SELECT 1 FROM dim.Cliente d
WHERE d.ClienteBK = s.ClienteID AND d.Corrente = 1);Power BI es la herramienta de visualización y análisis de Microsoft. Lo que sostiene un buen informe en ella no son los gráficos, sino el modelo: tablas relacionadas en estrella, con una dimensión calendario marcada como tabla de fechas.
DAX (Data Analysis Expressions) es el lenguaje de cálculo de Power BI. Se parece a una fórmula de Excel, pero trabaja sobre el modelo entero:
Faturamento = SUM(fVendas[ValorLiquido])
Faturamento Ano Anterior =
CALCULATE([Faturamento], SAMEPERIODLASTYEAR(dCalendario[Data]))
Crescimento % =
DIVIDE([Faturamento] - [Faturamento Ano Anterior], [Faturamento Ano Anterior])Usa DIVIDE() en lugar del operador /: trata la división por cero devolviendo vacío, en vez de un error que contamina el visual entero.
Sobre el modo de conexión: Import trae los datos dentro del archivo (rápido, con actualización programada) y DirectQuery consulta la base en cada interacción (siempre actualizado, pero traslada la lentitud de la consulta al usuario). Import es el valor por defecto correcto hasta que exista un motivo concreto para lo contrario.
QuickSight es el equivalente en AWS. Bastan dos términos: SPICE es el motor en memoria, el análogo del modo Import; dataset y analysis separan la preparación del dato del montaje visual. Quien entiende de modelado dimensional y sabe escribir SQL cambia de herramienta en días.
Un ERP (Enterprise Resource Planning) es el sistema que integra la operación de la empresa — ventas, inventario, finanzas, fiscal. Los de Microsoft tienen particularidades que generan errores en quien llega sin saberlas:
En Dynamics AX, la columna DATAAREAID separa las empresas dentro de la misma base. Toda consulta necesita filtrar por ella, si no sumas la facturación del grupo entero:
SELECT SUM(LINEAMOUNT) AS Total
FROM dbo.SALESLINE
WHERE DATAAREAID = 'BR01' -- sin esto, el número sale mal
AND CREATEDDATETIME >= '2026-01-01';Todavía en AX: RECID es la clave real de las tablas, y muchos campos son enums numéricos — SALESSTATUS = 3 significa "facturado", y el significado está en los metadatos del sistema, no en la base.
En Navision / Business Central, el nombre de la tabla incluye la empresa y usa $, lo que exige corchetes:
SELECT No_, Name FROM [CRONUS Brasil Ltda$Customer] WHERE Blocked = 0;El _ al final de No_ es como Navision escapa las palabras reservadas, y las fechas "vacías" suelen venir como 1753-01-01 — el mínimo del datetime — en lugar de NULL.
La recomendación vale para cualquier ERP: no consultes su base 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 sistema que la empresa usa para trabajar, eso evita que la siguiente actualización del ERP rompa veinte informes de golpe.