Jhonatan Pinheiro
Loading page...
Jhonatan Pinheiro
Loading page...
Jhonatan Pinheiro
Loading page...
T-SQL from SELECT to window functions, Views and indexed views, Procedures with TRY/CATCH and transactions, the three Function types and the scalar trap, investigating inconsistent data, execution plans, SSIS loads, SSRS paginated reports, star schema with Type 2 SCD, Power BI, Excel and a look inside Dynamics AX and Navision.
Existe um perfil de vaga que se repete no mercado brasileiro: analista de BI e banco de dados na stack Microsoft. O texto muda, as ferramentas não — T-SQL, Views, Stored Procedures, Functions, SSIS, SSRS, Excel e, como diferencial, Power BI, Data Warehouse e um ERP como Dynamics AX ou Navision.
Este guia cobre esse conjunto inteiro, na ordem em que ele aparece no dia a dia: primeiro a linguagem, depois os objetos que você cria dentro do banco, depois a investigação de dados errados, depois as ferramentas de carga e de relatório, e por fim a camada analítica.
Todo exemplo é T-SQL de verdade, testável no SQL Server. Onde o comportamento muda entre versões, está anotado.
Não decore sintaxe. Entenda onde cada peça encaixa — a sintaxe você consulta, o encaixe você precisa saber de cabeça para escolher a ferramenta certa na hora certa.
Antes do código, o mapa. Uma operação de BI na stack Microsoft costuma ter quatro camadas:
| Camada | Ferramenta | O que faz |
|---|---|---|
| Origem | ERP, sistemas, planilhas, APIs | Onde o dado nasce |
| Movimentação | SSIS | Extrai, transforma e carrega (ETL) |
| Armazenamento | SQL Server (OLTP e Data Warehouse) | Guarda e consulta |
| Consumo | SSRS, Power BI, Excel | Entrega o número para quem decide |
O erro clássico de quem está começando é achar que essas camadas competem. Elas não competem — cada uma resolve um problema que a outra resolve mal:
Se o pedido é "preciso do relatório de fechamento em PDF todo dia 1º às 6h no e-mail da diretoria", a resposta é SSRS. Se é "quero entender por que a margem caiu no Sul", a resposta é Power BI. Confundir isso gera meses de retrabalho.
T-SQL é o dialeto SQL da Microsoft. Tudo que vem depois — procedure, view, pacote SSIS, dataset de relatório — é T-SQL por baixo.
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;Três hábitos que separam código profissional de código improvisado:
dbo.Pedidos, não Pedidos). Sem isso o SQL Server procura primeiro no schema do usuário, e o plano de execução não é reaproveitado entre usuários diferentes.Nome em Pedidos daqui a dois anos, sua query não vai quebrar com "ambiguous column name".SELECT * em código que vai para produção. Ele traz colunas que você não usa, quebra quando a tabela muda e impede o otimizador de usar índices de cobertura.Esta é a query que aparece em toda base lenta:
-- ERRADO: a função sobre a coluna impede o uso do índice
SELECT * FROM dbo.Pedidos
WHERE YEAR(DataPedido) = 2026 AND MONTH(DataPedido) = 3;Aplicar função na coluna torna o predicado não-SARGable — o SQL Server precisa calcular YEAR() em cada linha da tabela para saber quais servem. O índice em DataPedido vira decoração.
-- CERTO: intervalo aberto no fim, o índice é usado
SELECT PedidoID, ClienteID, ValorTotal
FROM dbo.Pedidos
WHERE DataPedido >= '2026-03-01'
AND DataPedido < '2026-04-01';Repare no < no limite superior em vez de BETWEEN '2026-03-01' AND '2026-03-31'. Com datetime, BETWEEN perde tudo que aconteceu entre 2026-03-31 00:00:00.001 e 23:59:59.997. O intervalo semiaberto está sempre certo, em qualquer tipo de data.
E use sempre o formato AAAAMMDD ou AAAA-MM-DD em literais: são os únicos que o SQL Server interpreta igual em qualquer configuração de idioma.
-- Armadilha clássica: filtrar a tabela da direita no 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 os clientes sem pedidoO WHERE roda depois do JOIN. Cliente sem pedido tem ped.Status nulo, e NULL = 'FATURADO' é falso — o cliente some do resultado, e o LEFT JOIN vira INNER JOIN na prática.
-- Certo: a condição da tabela da direita vai no 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 cria um resultado nomeado e temporário. Serve para quebrar uma consulta grande em passos legíveis:
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;Uma ressalva honesta: CTE não é tabela temporária. Ela não materializa nada — o otimizador expande o texto dela no lugar onde é usada. Se você referencia a mesma CTE três vezes, ela é executada três vezes. Quando o resultado intermediário é caro e reusado, #tabela temporária costuma ser mais rápida.
WITH Hierarquia AS (
-- âncora: o topo da árvore
SELECT FuncionarioID, Nome, GerenteID, 0 AS Nivel
FROM dbo.Funcionarios
WHERE GerenteID IS NULL
UNION ALL
-- recursão: cada nível puxa o próximo
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);O MAXRECURSION é uma trava de segurança: sem ele, um ciclo nos dados (A é gerente de B, B é gerente de A) roda até estourar.
Window functions calculam sobre um conjunto de linhas sem colapsar o resultado, diferente do GROUP BY.
SELECT PedidoID,
ClienteID,
DataPedido,
ValorTotal,
-- total acumulado do cliente ao longo do tempo
SUM(ValorTotal) OVER (PARTITION BY ClienteID ORDER BY DataPedido
ROWS UNBOUNDED PRECEDING) AS Acumulado,
-- quanto foi o pedido anterior do mesmo cliente
LAG(ValorTotal) OVER (PARTITION BY ClienteID ORDER BY DataPedido) AS PedidoAnterior,
-- posição do pedido dentro do cliente
ROW_NUMBER() OVER (PARTITION BY ClienteID ORDER BY DataPedido DESC) AS Ordem
FROM dbo.Pedidos;A cláusula ROWS UNBOUNDED PRECEDING importa mais do que parece. Sem ela, o padrão é RANGE UNBOUNDED PRECEDING, que trata valores empatados como um bloco só e usa um spool em disco mais lento. Com datas repetidas, os dois dão resultados diferentes. Escreva ROWS sempre que a intenção for "linha a linha".
CROSS APPLY e OUTER APPLY são exclusivos do SQL Server e resolvem o clássico "o ú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 -- enxerga a linha de fora
ORDER BY ped.DataPedido DESC
) AS ult;OUTER APPLY mantém o cliente mesmo sem pedido (como LEFT JOIN); CROSS APPLY descarta (como INNER JOIN).
MERGE faz insert, update e delete em um comando só. É elegante e tem histórico de bugs documentados pela própria Microsoft, especialmente com tabelas particionadas, triggers e chaves estrangeiras.
-- Alternativa segura e previsível ao 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;O WHERE dest.Nome <> orig.Nome OR ... no UPDATE não é detalhe: sem ele você reescreve linhas idênticas, gera log de transação à toa e dispara triggers sem necessidade.
Uma view é uma consulta salva com nome. Ela não guarda dados — toda vez que você a consulta, a query por trás roda.
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 que serve de verdade:
Onde vira problema: view sobre view sobre view. Cada camada parece organizada, e o plano de execução final vira um monstro que lê tabelas que ninguém pediu. Duas camadas é razoável; quatro é dívida técnica.
Com SCHEMABINDING e um índice clusterizado único, o resultado é materializado em disco e mantido atualizado pelo SQL Server:
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 -- schema obrigatório com SCHEMABINDING
WHERE Status = 'FATURADO'
GROUP BY CAST(DataPedido AS date);
GO
CREATE UNIQUE CLUSTERED INDEX IX_vw_VendasPorDia ON dbo.vw_VendasPorDia (Dia);Regras que pegam todo mundo de surpresa: COUNT_BIG(*) é obrigatório em view agregada (não COUNT(*)), SUM sobre coluna que aceita NULL é proibido, e nada de OUTER JOIN, subconsulta ou DISTINCT. Em compensação, agregação que levava minutos passa a responder instantaneamente — ao custo de deixar todo INSERT na tabela base um pouco mais lento.
Procedure é código T-SQL guardado no banco, com parâmetros. É onde mora a lógica de carga, validação e regra de negócio.
CREATE OR ALTER PROCEDURE dbo.usp_FaturarPedido
@PedidoID INT,
@UsuarioID INT,
@Faturado BIT = 0 OUTPUT
AS
BEGIN
-- Sem isso, cada comando devolve "(N rows affected)" e alguns drivers
-- (inclusive o do SSIS) tratam essa mensagem como um resultado a mais.
SET NOCOUNT ON;
-- Garante que qualquer erro aborte a transação inteira, e não só o comando.
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: refaturar não faz 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; -- repropaga preservando número, mensagem e severidade
END CATCH
END
GOCinco detalhes que fazem diferença nesse bloco:
SET NOCOUNT ON elimina mensagens de contagem que confundem aplicações e pacotes SSIS.SET XACT_ABORT ON garante rollback em erros que, sozinhos, não abortariam a transação — deixando-a aberta e travando a tabela.XACT_STATE() distingue transação ainda válida de transação já condenada; IF @@TRANCOUNT > 0 sozinho não cobre esse caso.THROW sem argumento repropaga o erro original. RAISERROR exige reconstruir a mensagem e perde o número do erro.AND Status = 'ABERTO' torna a procedure idempotente: chamar duas vezes não fatura duas vezes. Em integração, isso vale ouro.O SQL Server compila o plano da procedure com base no primeiro valor recebido e o reaproveita. Se a primeira chamada foi @ClienteID = 12345 (três pedidos) e a próxima é para um cliente com dois milhões de pedidos, o plano otimizado para três linhas é usado para dois milhões.
-- Recompila só esta consulta a cada execução; o resto da procedure segue em cache
SELECT PedidoID, ValorTotal
FROM dbo.Pedidos
WHERE ClienteID = @ClienteID
OPTION (RECOMPILE);Alternativas conforme o caso: OPTIMIZE FOR UNKNOWN (usa a média das estatísticas), copiar o parâmetro para uma variável local (efeito parecido) ou, no SQL Server 2022+, deixar o Parameter Sensitive Plan optimization resolver sozinho.
Quando a procedure ficou lenta "sem ninguém mexer em nada", parameter sniffing é o primeiro suspeito.
Em vez de chamar a procedure mil vezes, mande as mil linhas de uma 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 -- TVP é sempre READONLY
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.PedidoItens (PedidoID, ProdutoID, Quantidade, ValorUnit)
SELECT @PedidoID, ProdutoID, Quantidade, ValorUnit
FROM @Itens;
END
GOO SQL Server tem três tipos de função, e escolher errado custa horas de execução.
Retorna um valor único. Parece inofensiva e é o maior assassino de performance da 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 em um SELECT sobre um milhão de linhas, ela é chamada um milhão de vezes, uma por linha, e até o SQL Server 2017 impedia paralelismo na consulta inteira. O SQL Server 2019 introduziu inlining automático de escalares, que resolve alguns casos — não todos, e nem sempre você controla a versão do servidor.
Um RETURN com uma única consulta. O otimizador a expande dentro da query que a chama, como se fosse uma view parametrizada. Custo praticamente zero:
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 com APPLY
SELECT cli.Nome, ped.PedidoID, ped.ValorTotal
FROM dbo.Clientes AS cli
CROSS APPLY dbo.tvf_PedidosDoCliente(cli.ClienteID) AS ped;Repare que não há BEGIN/END — é essa ausência que faz dela uma inline. A mesma lógica escrita com BEGIN ... RETURN ... END vira multi-statement e perde a otimização.
Declara uma variável de tabela, preenche em vários passos e retorna. O otimizador não enxerga o que tem dentro e estima um número fixo de linhas (1 até o SQL Server 2012, 100 depois), o que produz planos ruins:
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
GORegra prática: se cabe em uma consulta só, faça inline TVF. Se precisa de vários passos, prefira uma procedure gravando em #tabela temporária.
Boa parte do trabalho não é escrever consulta nova — é descobrir por que o número do relatório não bate. Estas consultas resolvem a maioria dos casos.
-- Quais chaves estão duplicadas e quantas vezes
SELECT CPF, COUNT(*) AS Qtd
FROM dbo.Clientes
WHERE CPF IS NOT NULL
GROUP BY CPF
HAVING COUNT(*) > 1
ORDER BY Qtd DESC;Para apagar mantendo a mais recente, ROW_NUMBER é a forma segura — o DELETE sobre a CTE apaga na tabela 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 qualquer
DELETEem produção: rode comoSELECTprimeiro, confira a contagem, e faça backup. UmDELETEsemWHEREcorreto é o erro mais 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);Prefira NOT EXISTS a NOT IN: se a subconsulta de NOT IN retornar um único NULL, o resultado inteiro vira vazio, silenciosamente. É um dos bugs mais difíceis de enxergar em SQL.
-- Reconciliação: cabeçalho contra soma dos itens
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;O > 0.01 em vez de <> 0 não é preguiça: com float ou arredondamento em moeda, diferenças de centavo aparecem por representação numérica e poluem o resultado. Use DECIMAL para dinheiro — nunca float.
-- 'ABC ' e 'ABC' parecem iguais na tela e podem não casar no JOIN
SELECT ClienteID, '[' + Codigo + ']' AS ComDelimitador, LEN(Codigo) AS Tamanho,
DATALENGTH(Codigo) AS Bytes
FROM dbo.Clientes
WHERE Codigo <> LTRIM(RTRIM(Codigo));LEN() ignora espaços à direita; DATALENGTH() não. Quando os dois discordam, você achou o problema. No SQL Server 2017+, TRIM() faz os dois lados de uma vez.
E cuidado com collation: se uma tabela é SQL_Latin1_General_CP1_CI_AS e a outra ..._CS_AS, o JOIN entre elas falha com erro de conflito de collation — ou pior, casa diferente do esperado (CI ignora maiúsculas, CS não).
-- Descobrir a collation de cada coluna
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;-- Corrupção em disco: rode em janela de manutenção, é pesado
DBCC CHECKDB ('MinhaBase') WITH NO_INFOMSGS, ALL_ERRORMSGS;
-- Confiança das foreign keys: 0 = confiável, 1 = não validada
SELECT name, is_not_trusted
FROM sys.foreign_keys
WHERE is_not_trusted = 1;Uma FK "não confiável" acontece depois de um BULK INSERT ou de um ALTER TABLE ... NOCHECK. O otimizador para de usá-la para simplificar planos, e ela deixa de garantir o que promete. Para revalidar:
ALTER TABLE dbo.Pedidos WITH CHECK CHECK CONSTRAINT FK_Pedidos_Clientes;O WITH CHECK CHECK duplicado não é erro de digitação: o primeiro manda validar os dados existentes, o segundo religa a constraint.
SET STATISTICS IO, TIME ON;
-- sua consulta aqui
SET STATISTICS IO, TIME OFF;STATISTICS IO mostra quantas páginas foram lidas por tabela. É o número mais honesto que existe: tempo varia com carga da máquina, leituras lógicas não. Se você mudou a consulta e as leituras caíram de 400.000 para 300, melhorou de verdade.
No SSMS, Ctrl+M liga o plano de execução real. Procure por:
INCLUDE.UPDATE STATISTICS dbo.Pedidos WITH FULLSCAN;CREATE NONCLUSTERED INDEX IX_Pedidos_Cliente_Data
ON dbo.Pedidos (ClienteID, DataPedido) -- colunas de filtro e ordenação
INCLUDE (ValorTotal, Status); -- colunas só devolvidasA ordem das colunas na chave importa: (ClienteID, DataPedido) serve para filtrar por cliente, e por cliente + data; não serve para filtrar só por data. Pense nela como a ordem das palavras num índice remissivo de livro.
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;No SQL Server 2016+, ative o Query Store no banco: ele guarda histórico de planos e permite forçar o plano bom quando um regride.
SSIS (SQL Server Integration Services) é a ferramenta de ETL da Microsoft. Você desenha o pacote no Visual Studio (com a extensão SQL Server Integration Services Projects) e ele roda no servidor, agendado pelo SQL Server Agent.
Um pacote tem duas superfícies, e confundi-las é o erro nº 1 de quem começa:
Regra de ouro: o que dá para fazer em SQL, faça em SQL. Uma transformação Sort do SSIS carrega tudo na RAM do servidor de integração; um ORDER BY na origem usa o índice do banco. Use o Data Flow para mover e para o que o banco não faz bem (ler arquivo, chamar API, dividir para vários destinos).
Quase toda carga séria segue três etapas:
stg.*, sem transformar nada. Se algo der errado, você tem o material original para investigar.Execute SQL Task). É mais rápido, mais fácil de testar e versiona melhor que componentes gráficos.Para carga incremental, guarde uma marca d'água em vez de reler a tabela inteira:
-- Tabela de controle
CREATE TABLE dbo.ControleCarga (
Tabela SYSNAME NOT NULL PRIMARY KEY,
UltimaCarga DATETIME2(3) NOT NULL,
LinhasCarregadas INT NOT NULL DEFAULT 0
);
-- Na origem, o pacote lê só o que mudou desde a ú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;Um detalhe que evita perda de dados: grave como nova marca d'água o horário de início da carga, não o horário do fim. Registros gravados na origem durante a execução do pacote ficariam num vão cego se você usasse o fim.
Todo componente do Data Flow tem uma saída de erro (a seta vermelha). Configure-a como Redirect Row e mande as linhas problemáticas para uma tabela de rejeitados, em vez de derrubar o pacote inteiro:
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()
);Uma nota fiscal com CNPJ inválido não pode impedir que as outras 50 mil entrem. Carregue o que é válido, separe o que não é, e mande a lista para quem cadastrou.
-- Checagens que devem rodar ao fim 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;Carga que não valida é carga que mente. A validação é o que transforma "o pacote rodou" em "o dado está certo".
Desde o SQL Server 2012, use o Project Deployment Model: o projeto vira um .ispac publicado no SSIS Catalog (SSISDB). Lá você cria Environments — Dev, Homologação, Produção — cada um com seus valores de conexão, e amarra a execução ao ambiente.
Nunca deixe string de conexão fixa dentro do pacote. Além do risco de rodar em produção achando que era teste, senha em pacote é senha em arquivo versionado.
Delay Validation = True em tarefas que dependem de objetos criados durante a execução, senão o pacote falha na validação inicial.Fast Load no destino OLE DB, com Rows per batch e Maximum insert commit size ajustados — a diferença para o modo linha a linha chega a dez vezes.Verbose só quando estiver caçando um problema (gera volume enorme).DFT_CargaPedidos, SQL_TruncaStaging, FLC_ArquivosDoDia. Pacote com "Data Flow Task 1" e "Execute SQL Task 4" é impossível de manter.SSRS (SQL Server Reporting Services) gera relatórios com layout fixo, feitos para paginar, imprimir e exportar. Você desenha no Report Builder ou no Visual Studio, e publica num portal web.
Todo .rdl tem quatro peças:
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) -- inclui o dia inteiro
AND ped.Status = 'FATURADO'
AND (@VendedorID IS NULL OR ped.VendedorID = @VendedorID)
ORDER BY ven.Nome, ped.DataPedido;
END
GODuas coisas aqui merecem atenção. O DATEADD(DAY, 1, @DataFim) com < resolve o problema clássico do relatório que "esquece" os pedidos do último dia. E o (@VendedorID IS NULL OR ...) é o padrão de parâmetro opcional — se ele deixar a consulta lenta, acrescente OPTION (RECOMPILE), que permite ao otimizador simplificar o predicado sabendo o valor real.
O segundo parâmetro depende do primeiro: escolher o estado filtra as cidades. Crie um dataset para cada lista e, no dataset de cidades, use o parâmetro de estado:
-- Dataset "Estados"
SELECT DISTINCT UF FROM dbo.Clientes ORDER BY UF;
-- Dataset "Cidades" — recebe @UF do primeiro parâmetro
SELECT DISTINCT Cidade FROM dbo.Clientes WHERE UF = @UF ORDER BY Cidade;Para parâmetro de múltipla escolha, o SSRS entrega uma lista; do lado do T-SQL, receba como texto e use STRING_SPLIT:
-- @Ufs chega 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, ','));Expressões começam com = e usam sintaxe Visual Basic:
' Total de um grupo
=Sum(Fields!ValorTotal.Value)
' Formatar moeda em reais
=Format(Fields!ValorTotal.Value, "C2")
' Zebra: alternar cor de fundo das linhas
=IIf(RowNumber(Nothing) Mod 2 = 0, "#F5F5F5", "White")
' Evitar divisão por zero — IIf avalia os dois lados, então proteja o divisor
=IIf(Sum(Fields!Meta.Value) = 0, 0,
Sum(Fields!Realizado.Value) / IIf(Sum(Fields!Meta.Value) = 0, 1, Sum(Fields!Meta.Value)))
' Numeração de páginas no rodapé
="Página " & Globals!PageNumber & " de " & Globals!TotalPagesAquele IIf aninhado no divisor não é firula: diferente de um if de linguagem normal, o IIf do SSRS avalia os dois ramos antes de escolher, então a divisão por zero acontece mesmo quando a condição diz para não dividir.
Uma subscription agenda a execução e entrega o resultado por e-mail ou em pasta de rede. É o que atende "todo dia 1º às 6h, o fechamento no e-mail da diretoria" sem ninguém clicar em nada.
A data-driven subscription (edição Enterprise) vai além: uma consulta define quem recebe o quê. Uma execução gera 40 relatórios, um por gerente regional, cada um com o filtro da sua região, enviados para o e-mail de cada um.
Interactive Size e Page Size definem o corte na tela e na impressão; deixar padrão gera PDF com páginas em branco entre cada página real.KeepTogether e RepeatColumnHeaders fazem o cabeçalho se repetir a cada página — sem isso, a página 7 é uma tabela sem títulos.Quando o volume cresce e o relatório passa a competir com o sistema transacional, separa-se o dado analítico num Data Warehouse. O modelo padrão é o star schema: uma tabela de fatos no centro, cercada de dimensões.
-- Dimensão: descreve, tem poucas linhas e muitas colunas
CREATE TABLE dim.Cliente (
ClienteSK INT IDENTITY PRIMARY KEY, -- surrogate key do DW
ClienteBK INT NOT NULL, -- business key: o ID do sistema origem
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 = versão vigente
Corrente BIT NOT NULL DEFAULT 1
);
-- Fato: mede, tem muitas linhas e poucas colunas
CREATE TABLE fato.Vendas (
VendaSK BIGINT IDENTITY PRIMARY KEY,
DataSK INT NOT NULL, -- FK para 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
);A surrogate key (ClienteSK) existe para o DW não depender do ID do sistema de origem — que pode mudar, repetir entre empresas ou sumir numa migração. A business key fica guardada para rastreabilidade.
Se um cliente muda de segmento, o faturamento do ano passado deve continuar somando no segmento antigo. É isso que a Dimensão de Mudança Lenta tipo 2 resolve: em vez de sobrescrever, ela encerra a versão atual e abre uma nova.
-- 1) Encerra a versão vigente do que mudou
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 a nova versão
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);Toda análise cruza tempo, e ninguém quer calcular trimestre com função em cada consulta. Gere uma tabela de datas uma 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
);
-- Preenche 20 anos sem laço, usando uma sequência gerada
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;O erro mais comum é começar pelos gráficos. O que sustenta um relatório Power BI é o modelo: tabelas relacionadas em estrela, com uma dimensão calendário marcada como tabela de datas.
Algumas medidas DAX que aparecem em todo projeto:
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]))Use DIVIDE() em vez do operador /: ele trata divisão por zero devolvendo vazio, em vez de erro que se espalha pelo visual inteiro.
Sobre o modo de conexão: Import traz os dados para dentro do arquivo (rápido, mas com agendamento de atualização) e DirectQuery consulta o SQL Server a cada interação (sempre atual, mas transfere a lentidão da consulta para o usuário). Import é o padrão certo até você ter um motivo concreto para o contrário.
Excel continua sendo onde o analista trabalha de verdade. Duas capacidades valem mais que cem fórmulas:
Power Query (Dados → Obter Dados) conecta direto no SQL Server, aplica transformações em passos nomeados e atualiza com um clique. É ETL dentro do Excel, e substitui aquele processo de copiar e colar todo mês.
Tabela dinâmica sobre a conexão permite explorar sem trazer as linhas para a planilha. Combinada com Power Query, entrega uma ferramenta de análise sem precisar de licença de BI.
Fórmulas que resolvem a maior parte do trabalho analítico:
=PROCX(A2; Base!A:A; Base!C:C; "não encontrado")
=SOMASES(Vendas[Valor]; Vendas[UF]; "SP"; Vendas[Mês]; 3)
=CONT.SES(Pedidos[Status]; "ABERTO")
=SEERRO(B2/C2; 0)
=TEXTO(A2; "dd/mm/aaaa")PROCX substitui PROCV com vantagens: procura em qualquer direção, não quebra quando alguém insere coluna e já tem o parâmetro "se não encontrar".
Quem trabalha com BI acaba consultando o banco de um ERP. Os da Microsoft têm particularidades que causam erro em quem chega sem saber.
DATAAREAID é a coluna que separa as empresas dentro da mesma base. Toda consulta precisa filtrar por ela, senão você soma o faturamento de todas as empresas do grupo:SELECT SUM(LINEAMOUNT) AS Total
FROM dbo.SALESLINE
WHERE DATAAREAID = 'BR01' -- sem isso, o número vem errado
AND CREATEDDATETIME >= '2026-01-01';RECID é a chave real das tabelas (não o campo de negócio), e RECVERSION controla concorrência otimista.SALESTABLE/SALESLINE, PURCHTABLE/PURCHLINE, INVENTTRANS para movimentação de estoque.SALESSTATUS = 3 significa "faturado". O mapeamento está nos metadados do AX, não no banco — documente numa tabela de-para no seu DW.$: CRONUS Brasil Ltda$Customer, CRONUS Brasil Ltda$Sales Header. Isso exige colchetes:SELECT No_, Name, [Country_Region Code]
FROM [CRONUS Brasil Ltda$Customer]
WHERE Blocked = 0;_ no fim é como o Navision escapa palavras reservadas (No_ é o "No.").tinyint (0/1), e datas "vazias" costumam vir como 1753-01-01, o mínimo do datetime — filtre por isso ao invés de IS NULL.Recomendação geral: nunca consulte o banco do ERP direto no relatório. Traga para uma staging, normalize os enums e nomes, e construa em cima disso. Além de proteger a performance do ERP, isolar essa camada evita que a próxima atualização do ERP quebre vinte relatórios de uma vez.
Se a empresa tem dados na AWS, o Amazon QuickSight é o equivalente ao Power BI naquele ecossistema. Dois conceitos bastam para se situar:
Conecta em Redshift, RDS (inclusive SQL Server), S3 via Athena e outros. Quem entende modelagem dimensional e sabe escrever SQL migra de ferramenta em dias — o que não se transfere rápido é o modelo de dados bem feito.
Se você está montando esse repertório do zero, esta é a ordem que rende mais rápido:
SET NOCOUNT ON, TRY/CATCH e transação desde o primeiro dia — vira hábito.Um último conselho, que vale mais que qualquer sintaxe deste guia: desconfie do número que fecha certo de primeira. Quando o relatório bate exatamente com o esperado logo na primeira execução, quase sempre há um filtro escondendo linhas — um INNER JOIN que devia ser LEFT, um WHERE que descarta nulos, uma empresa faltando no DATAAREAID. Reconcilie contra a origem antes de entregar. É essa checagem que constrói a confiança de quem depende do seu número para decidir.