02Bancos de dados5 min

Reescrevi o mesmo modelo de contratos mais de dez vezes

Um modelo de domínio complicado não sai certo de primeira. Converge-se até ele reescrevendo com os dados reais na frente, e isso inclui reverter decisões que pareciam corretas.

Por Salva Castells

Imagem de capa: Ernst Alban, Domínio público.
Neste post

O primeiro modelo de contratos que escrevi para uma ferramenta de BI de uma empresa grande tinha umas oitenta linhas de SQL e me pareceu bom. Um contrato, uma data de início, uma data de fim, algumas condições penduradas. Lia-se limpo. Um ano depois eu tinha reescrito esse mesmo modelo mais de dez vezes, incluindo uma em que mudei o nome porque o nome estava mentindo.

Não foi má planificação. Foi que o domínio só aparece quando entram os dados de verdade.

Por que o primeiro modelo sempre parece correto

Porque você o escreve contra o domínio que te contaram, não contra o que existe. Dizem "um contrato tem data de fim". Você modela. Funciona com as vinte linhas que você olha.

O primeiro aviso veio com o nome. O modelo se chamava kpi_02_20_revenue_contracts e eu o renomeei para kpi_02_20_rent_contracts. Não era revenue, era rent: contratos de aluguel em que a empresa paga, não em que recebe. Sinal contrário. O nome vinha do ticket, não da tabela. Um mês trabalhando com um modelo cujo nome era o oposto do que ele continha.

Nomes mal colocados não são cosmética. Alguém os lê e assume.

O que só aparece quando entram os dados de verdade

A data de fim foi o poço. A tabela tinha duas colunas: DT_FIRST_CONTRACT_END e DT_CONTRACT_END_NEXT. A primeira versão da data unificada era isto:

case
    when c.DT_FIRST_CONTRACT_END is null then c.DT_CONTRACT_END_NEXT
    when c.DT_CONTRACT_END_NEXT is null then c.DT_FIRST_CONTRACT_END
    when c.DT_FIRST_CONTRACT_END < CURRENT_DATE() then c.DT_CONTRACT_END_NEXT
    else c.DT_FIRST_CONTRACT_END
end as dt_end_merged

Parece exaustivo. Não é. Falta o caso das duas serem nulas, e falta o de a primeira estar no passado e a segunda não existir. Nos dados, ambos os casos significavam a mesma coisa: contrato indeterminado. O case os mandava para um null acidental que depois era contado como "expirado". A versão final adiciona esses dois ramos explícitos e um when dt_end_merged is null then True na flag is_unlimited, porque nulo ali é informação, não ausência de informação.

E essa mesma flag já carregava outra joia do mundo real:

when year_end_merged in (8909, 8899, 9999) then True

Três anos sentinela diferentes para dizer "sem fim". Nenhum documentado. Os três em produção.

As três vezes em que revertemos uma decisão

A mais ilustrativa é o ano 99. Nos Excel de origem havia datas de fim cujo ano vinha como 99, e alguém da equipe dizia que significava "indeterminado". Adicionei o tratamento:

case
    when {{ normalize_date('ew.WARUNEK_WA_NY_DO') }} like '%99%' then '2099-12-31'::date
    else {{ normalize_date('ew.WARUNEK_WA_NY_DO') }}::date
end

E o filtro correspondente, um or ... like '%99%' para que aquelas linhas não caíssem pela janela de datas. Tirei três dias depois. O like '%99%' casa com 1999, com 2099 e com qualquer data que tenha um 99 no dia ou no mês. Eu estava metendo contratos fechados havia vinte anos no cálculo de revenue ativo. A regra de negócio talvez fosse verdadeira; a implementação por like sobre uma string de data não era.

Dois meses depois o mesmo problema voltou por outra porta, no modelo de renegociações: anos de dois dígitos. Desta vez fiz sobre o ano já extraído, não sobre o texto:

case
  when year(normalized_next_possible_termination_old_contract) < 100
    then year(...) + 2000
  else year(...)
end

Isso também não sobreviveu intacto: duas semanas depois ficou comentado no arquivo com um -- ! na frente. Terceira volta sobre a mesma coluna.

O outro rollback grande foi o inner join entre contratos e condições. Um contrato sem condição não aparecia. Mudá-lo para left join mudou os números do dashboard, e essa é exatamente a conversa que é preciso ter com quem pede o KPI: se um contrato sem condição conta ou não conta. Na primeira vez decidi sozinho, com um inner, sem perceber que estava decidindo.

Como sei quando um modelo já está estável

Pelo tipo de commit. Durante o primeiro ano os commits eram estruturais: joins que mudam, filtros que entram e saem, colunas que são renomeadas. No fim do projeto os commits eram de outra classe: tirar um multiplicador por 100, fundir dois case num só, escrever a documentação de colunas. Isso é um modelo que já não se mexe por baixo, só por cima.

O outro sinal é que o .yml de documentação deixa de ter quatro bullets vagos e passa a listar os critérios de seleção exatos: entidades, tipos de contrato, moeda, o que se exclui e por quê. Quando você consegue escrever essa lista sem olhar o SQL, o modelo existe de verdade. Antes disso o que você tem é uma consulta.

O que faço hoje antes do primeiro CREATE TABLE

Antes eu escrevia o modelo e depois olhava os dados. Agora é o contrário, e nesta ordem:

  • Contar os nulos de cada coluna que vá entrar numa condição. Uma coluna com 40% de nulos não é uma data, são dois conceitos misturados.
  • Procurar valores sentinela. select distinct year(...) order by 1 desc. Se saírem 9999, 8899 ou 2099, há regras de negócio não documentadas escondidas ali.
  • Escrever o filtro em prosa antes de escrevê-lo em SQL. Se não sei dizer numa frase quais contratos entram, o where vai acabar com seis condições contraditórias e duas comentadas.
  • Normalizar sobre tipos, nunca sobre strings. Todo like sobre uma data é uma bomba-relógio.

Um modelo de domínio não se projeta: negocia-se com os dados, e os dados não cedem. Cada vez que revertei algo foi porque eu tinha decidido o que deveria ter perguntado.