No desenvolvimento de aplicações, muitas vezes somos pegos por mudanças referentes a um projeto, como é o caso de termos uma nova implementação de regra de negócio ou mesmo a aplicação de funcionalidades mais complexas, por exemplo. Com desenvolvedores de bancos de dados isso não é diferente, de forma a termos que desenvolver funções ou mesmo procedures genéricas, carregadas com vários parâmetros e vários tipos de resultados. De todo modo, o paradoxo no qual nos encontramos, de querer aplicar tudo o que queremos num único lugar, não nos favorece. Neste artigo abordaremos alguns dos princípios do DRY.

Em muitos casos, temos que nossos códigos funcionam perfeitamente dentro do propósito inicial, mas caso haja uma mudança referente a sua funcionalidade para um banco de dados diferente, aí o mesmo para de funcionar. Se temos o código funcionando e vamos utilizá-lo em outro banco de dados apenas adaptando, não podemos simplesmente copiar o mesmo, porque a cada vez que precisarmos copiar e colar o código, estaremos nos expondo a riscos, como mudança de requisitos.

Para que possamos ter o código necessário para resolvermos determinados problemas ou mesmo executar determinadas regras, temos a necessidade de reformular o nosso código comum a todos, de forma a termos uma única unidade de código reutilizável, sendo este, uma trigger, Stored Procedure, índice ou mesmo uma função definida pelo usuário. Independente da abordagem que será utilizada, esta forma de reutilização de código reduz significativamente a possibilidade de ocorrência de erros, além de ser um item de extrema importância para a programação defensiva. No entanto, muitas são as dificuldades encontradas pelos desenvolvedores para escolher a aplicação correta dependendo da exigência. Eis que neste artigo trabalharemos com algumas indicações úteis para termos tais códigos melhorados.

Problemas com cópia de código

Muitas vezes, para agilizarmos o processo de desenvolvimento, buscamos reaproveitar códigos através de cópias, devido a sua similaridade com o que estamos precisando. Um problema que encontramos é justamente referente a duplicação de código, o que significa manutenção de múltiplas cópias do mesmo código, mas com modificações sutis de forma a atender necessidades específicas. O problema nesse caso é quando os requisitos mudam e, devido a isso, precisamos ter certeza de que estas mudanças serão refletidas não apenas no código original, mas também em todas as cópias subsequentes. Para demonstrarmos esse tipo de problema, vejamos o exemplo da Listagem 1, onde criaremos uma tabela de vendas e carregamos com alguns dados de teste. Utilizaremos nesse artigo o SQL Server 2014, mas podem utilizar outras versões também.

Listagem 1. Criação da tabela Vendas e adicionando dados para teste.

CREATE TABLE dbo.Vendas
        (
        codVendas INT NOT NULL IDENTITY PRIMARY KEY,
        Cidade VARCHAR(50) NOT NULL ,
        DataVendas DATETIME NOT NULL ,
        Valor DECIMAL(10, 2) NOT NULL
        );
        GO

        SET NOCOUNT ON;
        DECLARE @data DATETIME,
        @inteiro INT;

        SET @data = '20150802';
        SET @inteiro = 0;
        WHILE @inteiro < 250
        BEGIN;
        INSERT INTO dbo.Vendas (Cidade, DataVendas, Valor)
        SELECT 'Recife', @data,
        case WHEN @data < '20150801' THEN 250000
        ELSE 25000
        END
        UNION ALL
        SELECT 'Olinda', @data,
        case WHEN @data < '20150801' THEN 100000
        ELSE 1000
        END;
        SELECT @data = DATEADD(day, -1, @data),
        @inteiro = @inteiro + 1;
        END;

Após a criação e população da tabela, criaremos agora uma Stored Procedure, a qual chamaremos de PegaTotalVendas, para retornar o total de vendas por Cidade para um determinado mês. Dito isso, podemos ver a Stored procedure de acordo com a apresentada na Listagem 2.

Listagem 2. Criação da Stored Procedure PegaTotalVendas.

CREATE PROCEDURE dbo.TotalVendasCidadePorMes
        @dataApresentada DATETIME
        AS
        SELECT SUM(Valor) AS VendasCidade,
        Cidade
        FROM dbo.Vendas
        -- O mês começa no primeiro dia do calendário para o referido mês
        WHERE DataVendas >= DATEADD(month, DATEDIFF(month, '19900101', @dataApresentada), '19900101') AND DataVendas
        <= @dataApresentada
        GROUP BY Cidade;

No momento em que desenvolvemos nosso código, o intuito dele era para utilizarmos em um relatório para um dado mês, de forma que abranja o período de tempo desde o primeiro dia de calendário do mês até o dia em que executarmos o relatório.

Agora, temos em uma nova solicitação, trazer a média de vendas por Cidade para um determinado mês. Podemos então perceber que este novo relatório realiza praticamente as mesmas operações para um “determinado mês”. Dessa forma, bastaria copiarmos a procedure criada e, em seguida, substituir a função SUM pela função AVG, e com isso, teríamos a nossa nova requisição implementada, como podemos ver na Listagem 3.

Listagem 3. Retornando a média de vendas no mês.

CREATE PROCEDURE dbo.MediaValoresCidadeMes
        @dataApresentada DATETIME
        AS
        SELECT AVG(Valor) AS VendasCidade,
        Cidade
        FROM dbo.Vendas
        -- O mês começa no primeiro dia do calendário para o referido mês
        WHERE DataVendas >= DATEADD(month, DATEDIFF(month, '19900101', @dataApresentada), '19900101')
        AND DataVendas <= @dataApresentada
        GROUP BY Cidade;

Com isso finalizamos uma nova atividade, porém, em um determinado momento, foi solicitada a alteração da definição de "para um dado mês" para "vendas durante trinta dias consecutivos, terminando no dia em que executarmos o relatório". Podemos perceber que temos uma mesma definição sendo aplicada duas vezes no nosso projeto que retorna as informações "para um dado mês", sendo este feito nas Stored Procedures PegaTotalVendas e MediaValoresCidadeMes. Mesmo que a criação das procedures tenha sido realizada pela mesma pessoa, mas em tempos diferentes, pode ser que alguma dessas funções não tenham as modificações necessárias. Contudo, mesmo que estas procedures estejam bem documentadas e que ambos os processos devam utilizar a mesma definição, ainda assim, é possível que o desenvolvedor não consiga realizar as alterações em ambas as procedures de maneira consistente. Dito isso, vejamos um exemplo simples onde a Stored Procedure MediaValoresCidadeMes será modificada para atender a essa nova exigência, como mostra a Listagem 4.

Listagem 4. Modificando a Stored Procedure MediaValoresCidadeMes.

ALTER PROCEDURE dbo.MediaValoresCidadeMes
        @dataApresentada DATETIME
        AS
        SELECT AVG(Valor) AS VendasCidade,
        Cidade
        FROM dbo.Vendas
        -- Vendas nos 30 dias consecutivos
        WHERE DataVendas >= DATEADD(DAY, -29, @dataApresentada)
        AND DataVendas <= @dataApresentada
        GROUP BY Cidade;

Quando realizamos a alteração na procedure e a temos funcionando, torna-se fácil o esquecimento de que a temos implementada em dois lugares diferentes. Com isso, se atualizarmos a definição em um lugar e não no outro, teremos a apresentação de resultados inconsistentes, como podemos ver de acordo na Listagem 5.

Listagem 5. Apresentação de resultados diferentes com as Stored Procedures criadas.

PRINT 'Total de vendas por cidade por mês:';
        EXEC dbo.TotalVendasCidadePorMes
        @dataApresentada = '20150605';

        PRINT 'Média de vendas por mês para cada cidade:' ;
        EXEC dbo.MediaValoresCidadeMes
        @dataApresentada = '20150605';

Ao executarmos esta operação temos como resultado uma divergência considerável em relação ao tamanho médio das vendas para as cidades de Recife e Olinda, como podemos ver na Figura 1.

Resultado obtido das Stored Procedures

Figura 1. Resultado obtido das Stored Procedures.

Claro que, a título de testes, utilizamos dados que apresentariam uma diferença significativa nos resultados. No entanto, estas diferenças podem ser quase difíceis de serem notadas, o que tornaria mais difícil de detectar o erro com o passar do tempo. Este é um exemplo claro de que, ao copiarmos um determinado código, estamos expondo-o a possíveis erros quando houverem mudanças de requisitos, o que resulta em falha humana ao mudarmos múltiplas implementações de uma mesma lógica quando as tratamos da mesma maneira. Para clarear um pouco mais sobre esse “erro” temos que o processo de copiar um código acaba sendo uma violação direta aos princípios do DRY (Don’t Repeat Yourself), que é de fundamental importância para a engenharia de software.

O que é o DRY

O DRY é um conceito presente na programação computacional que propõe que a cada porção de conhecimento em um sistema deve possuir uma representação única, livre de ambiguidades em todo o sistema. Esta expressão foi apresentada pelos autores Andy Hunt e Dave Thomas no seu livro The Pragmatic Programmer. Este conceito pode ser aplicado amplamente, desde a criação de esquemas de bancos de dados, planos de testes, compilações de código, dentre outros processos. No momento da utilização deste conceito temos que ele realizará modificações em apenas partes relacionadas do sistema. Dessa forma, todos os elementos relacionados mudam de forma previsível e uniforme, mantendo-se dessa forma, sincronizados.

Dito isso, temos que o código para implementarmos uma determinada lógica deve ser implementado uma única vez, em definitivo, e reutilizado por todos os aplicativos que precisam dele. No entanto, é claro, que devidos cuidados devem ser tomados quando estamos tratando da reutilização de código SQL. Reutilização sem os devidos cuidados podem nos levar a problemas de manutenção e desempenho nos nossos sistemas, especialmente quando esta reutilização assume a forma de UDF’s escalares. Podemos ver, no entanto, que para realizamos o processo de reutilização de código precisamos verificar se ele será executado de forma rápida suficiente.

Melhorando o desempenho com reutilização de código

Com base nos exemplos apresentados até o momento, temos que a melhor alternativa é a refatoração das funcionalidades comuns para nossas procedures. Ao invés de repetirmos a mesma lógica em diversos lugares, podemos implementar a definição de "vendas para um determinado mês" em uma UDF (User Defined Function - Função definida pelo usuário) inline, como podemos ver na Listagem 6.

Listagem 6. Criando a Function VendasMes.

CREATE FUNCTION dbo.VendasMes(@dataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (SELECT codVendas,
        Cidade,
        DataVendas,
        Valor
        FROM dbo.Vendas
        WHERE DataVendas >= DATEADD(day, -29, @dataVenda)
        AND @dataVenda <= @dataVenda
        );

Com a definição dessa função, temos um parâmetro que será passado como uma data inline através de uma UDF, assim podemos utilizá-la em ambas as Stored Procedures que criamos, como podemos ver definido na Listagem 7.

Listagem 7. Utilizando a nova função inline em ambas Stored Procedures.

ALTER PROCEDURE dbo.TotalVendasCidadePorMes
        @dataVenda DATETIME
        AS
        BEGIN
        SELECT SUM(Valor) AS VendasCidade,
        Cidade
        FROM dbo.VendasMes(@dataVenda)
        GROUP BY Cidade;
        END;
        GO
        ALTER PROCEDURE dbo.MediaValoresCidadeMes
        @dataVenda DATETIME
        AS
        BEGIN
        SELECT AVG(Valor) AS VendasCidade,
        Cidade
        FROM dbo.VendasMes(@dataVenda)
        GROUP BY Cidade;
        END;
        GO

Após esta simples refatoração temos nossas duas Stored Procedures garantindo a mesma definição de "para um determinado mês". Com a criação da função VendasMes basta apenas que a utilizemos nas nossas procedures e qualquer data que venha a ser passada não oferecerá riscos referentes a dados incorretos. Dessa forma, podemos reutilizar a definição do período de referência em outras consultas em outras tabelas. Nós podemos pelo menos tentar ir um passo além e ter um módulo de definição do nosso período de relatório. Primeiro, precisamos averiguar o desempenho, e o código presente na Listagem 8 apresenta como podemos implementar a definição de um período como uma UDF em linha de relatório.

Listagem 8. Implementação da UDF para definir um período de relatório.

CREATE FUNCTION dbo.RelatorioPorMesDiaInicio
        (@DataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (SELECT DATEADD(day, -29, @DataVenda) AS DataInicial);

Podemos utilizar a UDF inline quando implementamos as funcionalidades de "vendas para um determinado mês", como podemos ver na Listagem 9.

Listagem 9. Utilizando a nova função RelatorioPeriodoMes em VendasCidadeMes.

ALTER FUNCTION dbo.VendasMes(@DataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (SELECT codVendas,
        Cidade,
        DataVendas,
        Valor
        FROM dbo.Vendas AS VendaCidade
        CROSS APPLY
        dbo.RelatorioPorMesDiaInicio(@DataVenda) AS relatorio
        WHERE DataVendas >= relatorio.DataInicial
        AND DataVendas <= @DataVenda
        );

De forma alternativa, podemos utilizar uma UDF escalar para implementarmos a definição do período do relatório, como podemos ver no código da Listagem 10. Assim, antes de realizarmos este procedimento, precisamos excluir as implementações anteriores para que os períodos sejam apresentados em apenas um lugar, por isso utilizamos o DROP.

Listagem 10. Implementando uma UDF Scalar para a definição do período do relatório.

DROP FUNCTION dbo.RelatorioPorMesDiaInicio;
        GO

        CREATE FUNCTION dbo.RelatorioPorMesDiaInicio
        (@DataVenda DATETIME)
        RETURNS DATETIME
        AS
        BEGIN;
        DECLARE @retorno DATETIME;
        SET @retorno = DATEADD(day, -29, @DataVenda);
        RETURN @retorno;
        END;

Em seguida, precisaremos modificar a nossa função VendasMes para utilizarmos a nova UDF escalar, como podemos ver na Listagem 11.

Listagem 11. Alterando VendasMes para utilizar o novo UDF escalar RelatorioPeriodoMes.

ALTER FUNCTION dbo.VendasMes(@DataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (SELECT codVendas,
        Cidade,
        DataVendas,
        Valor
        FROM dbo.Vendas AS vendaCidade
        WHERE DataVendas >= dbo.RelatorioPorMesDiaInicio(@DataVenda)
        AND DataVendas <= @DataVenda
        );

Percebam que a nova aplicação de VendasMes é bem mais simples, de forma que, ao invés de usarmos a cláusula CROSS APPLY, utilizamos apenas a UDF inline. Com isso, podemos simplesmente invocar a UDF escalar diretamente na cláusula WHERE. Entretanto, a utilização do CROSS APPLY apresentará um melhor desempenho em muitos casos. Devido a isso, sempre que precisarmos realizar o processo de reutilização de código, precisamos verificar o desempenho que cada uma das abordagens possa oferecer. Em alguns casos, funções de encadeamento podem acarretar em um mau desempenho, dependendo dos resultados obtidos nos nossos testes, de forma que possa ser preciso abandonarmos as recém-criadas UDF’s de VendasMes e RelatorioPeriodoMes e retornarmos para as funções mais simples apresentadas anteriormente.

O que pretendemos apresentar aqui é que ao aplicarmos a mesma lógica em diversos pontos, podemos gerar erros no momento em que nossas necessidades mudarem. Ao invés disso, devemos reutilizar códigos sempre que possível, e as UDF’s são apenas um dos meios para alcançar este objetivo.

Trabalhando com VIEWS

Em determinados casos precisamos “empacotar” (processo conhecido também como Wrapping) as nossas consultas para serem utilizadas em VIEWS para retornar um resultado, como podemos ver no exemplo da Listagem 12.

Listagem 12. Empacotando uma consulta em uma VIEW.

CREATE VIEW dbo.TotalVendasCidade
        AS
        SELECT SUM(Valor) AS ValorTotal, Cidade
        FROM dbo.Vendas
        GROUP BY Cidade;

Neste caso podemos selecionar, a partir da VIEW, os dados da mesma forma como realizamos o processo a partir de tabelas, por isso que a utilização delas se torna mais conveniente e útil. No entanto, as VIEWS não nos oferecem a possibilidade de trabalhar com parâmetros para as instruções SELECT que estamos reutilizando. No momento em que esta questão se torna uma necessidade, podemos reutilizar a nossa instrução SELECT tanto em Stored Procedures como em Functions definidas pelo usuário. Dependendo do retorno das consultas, é necessário que verifiquemos sempre o desempenho antes e depois das mudanças serem realizadas.

Caso a nossa necessidade seja de reutilizarmos nossas consultas parametrizadas, o mais indicado é a utilização de FUNCTIONS definidas pelo usuário, ao invés de Stored Procedures, como podemos ver na Listagem 13, onde uma Stored Procedure retorna todas as vendas para o presente mês e para todas as cidades cadastradas.

Listagem 13. Criando uma Stored Procedure que retorna todas as vendas para o mês.

CREATE PROCEDURE dbo.VendasMes @DataVenda DATETIME
        AS
        BEGIN;
        SELECT Valor,
        Cidade
        FROM dbo.Vendas
        WHERE DataVendas >= DATEADD(day, -29, @DataVenda)
        AND DataVendas <= @DataVenda;
        END;
        GO

Neste momento precisaremos desenvolver uma Stored Procedure para recuperar o total de vendas por cidade para um determinado mês, e em seguida, iremos voltar a utilizar a Stored Procedure VendasMes. Criaremos uma variável de tabela ou uma tabela temporária com uma estrutura que corresponda à estrutura do conjunto de resultados retornados pela Stored Procedure, como vemos na Listagem 14.

Listagem 14. Retornando o total das vendas por cidade para o mês utilizando as duas Procedures.

CREATE PROCEDURE dbo.VendasPorCidadePorMes
        @DataVenda DATETIME
        AS
        BEGIN;
        DECLARE @VendasMes TABLE
        (
        Cidade VARCHAR(50),
        Valor DECIMAL(10, 2)
        );
        INSERT INTO @VendasMes
        (
        Valor,
        Cidade
        )

        EXEC dbo.VendasMes @DataVenda;
        SELECT SUM(Valor) AS TotalVendasMes,
        Cidade
        FROM @VendasMes
        GROUP BY Cidade
        ORDER BY Cidade;
        END;
        GO

Podemos executar um teste para verificar que as nossas Stored Procedures estão funcionando corretamente, como mostram as instruções a seguir:

EXEC dbo.VendasMes @DataVenda = '20150703';
        EXEC dbo.VendasPorCidadePorMes @DataVenda = '20150703';

Até o momento tudo está funcionando perfeitamente no que se refere a Stored Procedure VendasMes. No entanto, neste momento queremos selecionar a cidade com o maior total de vendas para um determinado mês. Parece que podemos simplesmente reutilizar o procedimento VendasPorCidadePorMes, realizando uma pequena adaptação para criar uma variável de tabela ou uma tabela temporária, como mostra a Listagem 15.

Listagem 15. Reutilizando a Stored Procedure VendasPorCidadePorMes para pegar a cidade com mais vendas.

CREATE PROCEDURE dbo.CidadeComMaiorVenda
        @DataVenda DATETIME
        AS
        BEGIN;
        DECLARE @VendaPorMes TABLE
        (
        Valor DECIMAL(10, 2) ,
        Cidade VARCHAR(50)
        );

        INSERT INTO @VendaPorMes
        (
        Valor,
        Cidade
        )
        EXEC dbo.VendasCidadePorMes @DataVenda;
        SELECT TOP (1)
        Valor,
        Cidade
        FROM @VendaPorMes
        ORDER BY Valor DESC;
        END;

Ao executarmos este exemplo vemos que a procedure não funciona devido a abordagem INSERT ... EXEC que usamos na procedure VendasPorCidadePorMes, que não pode ser aninhada. Para resolvermos isso, podemos utilizar as UDF’s inline, como podemos ver na Listagem 16.

Listagem 16. Implementando a mesma funcionalidade através de UDFs inline.

CREATE FUNCTION dbo.VendasPorCidadeMes
        (@DataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (
        SELECT CodVendas, SUM(Valor) AS TotalVendas
        FROM dbo.VendasPorCidadeMes(@DataVenda)
        GROUP BY CodVendas
        );
        GO

        CREATE FUNCTION dbo.MaisVendasCidadeMes
        (@DataVenda DATETIME)
        RETURNS TABLE
        AS
        RETURN
        (
        SELECT TOP (1)
        CodVendas,
        TotalVendas
        FROM dbo.VendasPorCidadeMes(@DataVenda)
        ORDER BY TotalVendas DESC
        );

Para testarmos nossas procedures, basta executar o seguinte trechos de código a seguir:

SELECT * FROM dbo.VendasPorCidadeMes('20150602');
        SELECT * FROM dbo.MaisVendasCidadeMes('20150602');

Em muitos momentos, torna-se mais fácil trabalharmos com as UDF’s para reutilização de código quando este encontra-se “empacotado” numa Stored Procedure. Alguns pontos devem ser levados em consideração no que diz respeito a utilização de UDF’s ou Stored Procedures, como é o caso do exec INSERT, que requer a criação de uma variável de tabela ou uma tabela temporária antes de fazermos a chamada, já que as Stored Procedures podem ter múltiplos conjuntos de resultados. Outro ponto a ser avaliado é que certas funcionalidades, tais como blocos try ... catch, não são permitidos em UDF’s, e por fim, UDF’s inline, como VIEWS, são expandidas no plano de execução, dando ao otimizador a opção de tomar atalhos, ou mesmo remover tabelas associadas, caso suas colunas não estejam sendo utilizadas.

Esperamos que tenham gostado. Até a próxima!