Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway

Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway

Um esquema de banco muda ao longo da vida de um sistema, e os repositórios se repetem de tabela em tabela. Nesta aula você constrói um Flyway em miniatura antes de usar o de verdade e gera repositórios a partir de interfaces com o Spring Data JPA, depois de montar uma versão que transforma nomes de método em SQL.
Java

• • 29 min de leitura

Um esquema de banco muda ao longo da vida de um sistema, e os repositórios se repetem de tabela em tabela. Nesta aula você resolve os dois problemas: constrói um Flyway em miniatura antes de usar o de verdade, e gera repositórios a partir de interfaces com o Spring Data JPA, também depois de montar uma versão de brinquedo que transforma nomes de método em SQL.

"Objetos no Banco" deixou dois incômodos. O primeiro é o schema.sql, executado a cada inicialização com CREATE TABLE IF NOT EXISTS. Ele funciona enquanto o esquema não muda. No dia em que a trilha ganhar uma coluna de altitude, o IF NOT EXISTS vai ignorar a mudança num banco que já existe. O banco de desenvolvimento, o de testes e o de produção começam então a divergir, e ninguém sabe dizer em que versão cada um está. O segundo incômodo é o repositório, escrito à mão com o EntityManager e a JPQL, que se repetiria, quase igual, para cada entidade do sistema. Esta aula apresenta a ferramenta mais usada para cada problema: o Flyway para as migrações de esquema e o Spring Data JPA para os repositórios. Como nas aulas anteriores, cada uma começa com uma miniatura que funciona, para que nada pareça mágica.

Migrações: o esquema como uma sequência de passos

A ideia das migrações é tratar o esquema do banco como o código: uma sequência de mudanças versionadas, guardadas no repositório do projeto, aplicadas sempre na mesma ordem. Cada mudança é um script numerado. O banco guarda numa tabela quais scripts já recebeu. Ao subir a aplicação, a ferramenta compara a pasta com a tabela e aplica o que faltar. Um banco novo recebe todos os scripts, desde o primeiro, e um banco de produção com dois anos de vida recebe só os dois últimos. Os dois terminam com o mesmo esquema.

O Flyway, criado por Axel Fontaine em 2010 e hoje mantido pela Redgate, segue essa ideia com uma convenção de nomes: V1__esquema_inicial.sql, V2__altitude_e_data_de_cadastro.sql, com o V, a versão, dois sublinhados e uma descrição. Para entender o que ele faz, vale construir um em miniatura.

As migrações do projeto das Trilhas da Ilha são três. A primeira é o esquema de "Dados que Sobrevivem":

-- V1__esquema_inicial.sql
CREATE TABLE trilha (
    id            INTEGER PRIMARY KEY,
    nome          TEXT    NOT NULL UNIQUE,
    regiao        TEXT    NOT NULL,
    distancia_km  REAL    NOT NULL CHECK (distancia_km > 0),
    dificuldade   TEXT    NOT NULL CHECK (dificuldade IN ('FACIL', 'MODERADA', 'DIFICIL')),
    duracao_min   INTEGER NOT NULL CHECK (duracao_min > 0)
) STRICT;

CREATE TABLE ponto_de_interesse (
    trilha_id  INTEGER NOT NULL REFERENCES trilha (id) ON DELETE CASCADE,
    ordem      INTEGER NOT NULL,
    nome       TEXT    NOT NULL CHECK (length(nome) <= 100),
    PRIMARY KEY (trilha_id, ordem)
) STRICT;

CREATE TABLE avaliacao (
    id          INTEGER PRIMARY KEY,
    trilha_id   INTEGER NOT NULL REFERENCES trilha (id) ON DELETE CASCADE,
    autor       TEXT    NOT NULL,
    nota        INTEGER NOT NULL CHECK (nota BETWEEN 1 AND 5),
    comentario  TEXT,
    data        TEXT    NOT NULL
) STRICT;

CREATE INDEX idx_avaliacao_trilha ON avaliacao (trilha_id);

A segunda acrescenta duas colunas à trilha, e a terceira grava dados de referência:

-- V2__altitude_e_data_de_cadastro.sql
-- Cada trilha passa a registrar a altitude máxima (opcional) e quando foi cadastrada
ALTER TABLE trilha ADD COLUMN altitude_max_m INTEGER CHECK (altitude_max_m > 0);
ALTER TABLE trilha ADD COLUMN cadastrada_em TEXT NOT NULL DEFAULT '2026-01-01';
-- V3__trilhas_iniciais.sql
-- Dados de referência: entram uma vez, como qualquer outra mudança
INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, altitude_max_m) VALUES
    ('Lagoinha do Leste', 'Sul', 4.5, 'MODERADA', 120, 310),
    ('Praia do Saquinho', 'Sul', 2.1, 'FACIL', 60, NULL);

A coluna cadastrada_em é NOT NULL, e numa tabela que já tem linhas isso só é possível com um DEFAULT: o banco precisa saber que valor dar às trilhas que já existiam. Esse cuidado não aparece quando se cria o esquema de uma vez, e é típico das migrações. Cada mudança precisa levar em conta os dados que já estão lá.

Um Flyway em miniatura

A miniatura cabe numa classe. Ela procura os scripts numa pasta, compara com uma tabela de histórico, aplica os que faltam em ordem, e guarda o checksum de cada um, uma impressão digital do conteúdo, com o SHA-256 que o JDK oferece:

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.sql.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.Stream;

/**
 * Um Flyway em miniatura: aplica, em ordem, os scripts V<versão>__<descrição>.sql de uma pasta
 * que ainda não foram aplicados, e registra cada um numa tabela de histórico com o seu checksum.
 */
public final class MiniFlyway {
    private static final Pattern NOME = Pattern.compile("V(\\d+)__(\\w+)\\.sql");

    record Migracao(int versao, String descricao, Path arquivo, String checksum) {}

    private final Connection conexao;
    private final Path pasta;

    MiniFlyway(Connection conexao, Path pasta) {
        this.conexao = conexao;
        this.pasta = pasta;
    }

    void migrar() throws SQLException, IOException {
        try (Statement s = conexao.createStatement()) {
            s.execute("""
                    CREATE TABLE IF NOT EXISTS historico_do_esquema (
                        versao INTEGER PRIMARY KEY, descricao TEXT NOT NULL,
                        checksum TEXT NOT NULL, aplicada_em TEXT NOT NULL)""");
        }
        Map<Integer, String> aplicadas = new HashMap<>();
        try (Statement s = conexao.createStatement();
             ResultSet rs = s.executeQuery("SELECT versao, checksum FROM historico_do_esquema")) {
            while (rs.next()) aplicadas.put(rs.getInt(1), rs.getString(2));
        }
        for (Migracao m : encontrar()) {
            String checksumGravado = aplicadas.get(m.versao());
            if (checksumGravado != null) {
                if (!checksumGravado.equals(m.checksum()))       // alguém editou um script já aplicado
                    throw new IllegalStateException("V" + m.versao() + " foi alterada depois de aplicada: checksum "
                            + checksumGravado + " no banco, " + m.checksum() + " no arquivo");
                continue;
            }
            aplicar(m);
        }
    }

    private List<Migracao> encontrar() throws IOException {
        List<Migracao> lista = new ArrayList<>();
        try (Stream<Path> arquivos = Files.list(pasta)) {
            for (Path p : arquivos.toList()) {
                Matcher mt = NOME.matcher(p.getFileName().toString());
                if (!mt.matches()) continue;
                lista.add(new Migracao(Integer.parseInt(mt.group(1)), mt.group(2).replace('_', ' '), p, checksum(p)));
            }
        }
        lista.sort(Comparator.comparingInt(Migracao::versao));
        return lista;
    }

    /** Cada migração numa transação: se um comando falhar, nada dela fica no banco. */
    private void aplicar(Migracao m) throws SQLException, IOException {
        conexao.setAutoCommit(false);
        try (Statement s = conexao.createStatement()) {
            String script = Files.readString(m.arquivo(), StandardCharsets.UTF_8).replaceAll("(?m)^\\s*--.*$", "");
            for (String comando : script.split(";"))
                if (!comando.isBlank()) s.execute(comando);
            try (PreparedStatement ps = conexao.prepareStatement(
                    "INSERT INTO historico_do_esquema VALUES (?, ?, ?, datetime('now'))")) {
                ps.setInt(1, m.versao());
                ps.setString(2, m.descricao());
                ps.setString(3, m.checksum());
                ps.executeUpdate();
            }
            conexao.commit();
            System.out.println("  aplicada: V" + m.versao() + " " + m.descricao());
        } catch (SQLException e) {
            conexao.rollback();
            throw new SQLException("falha em V" + m.versao() + " " + m.descricao() + ": " + e.getMessage(), e);
        } finally {
            conexao.setAutoCommit(true);
        }
    }

    private static String checksum(Path arquivo) throws IOException {
        try {
            byte[] hash = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(arquivo));
            return HexFormat.of().formatHex(hash, 0, 4);     // os 8 primeiros dígitos bastam para o exemplo
        } catch (java.security.NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        try (Connection c = DriverManager.getConnection("jdbc:sqlite:" + args[0])) {
            System.out.println("migrando " + args[0] + ":");
            new MiniFlyway(c, Path.of(args[1])).migrar();
            try (Statement s = c.createStatement();
                 ResultSet rs = s.executeQuery("SELECT versao, descricao, checksum FROM historico_do_esquema ORDER BY versao")) {
                while (rs.next()) System.out.println("  histórico: V" + rs.getInt(1) + " | " + rs.getString(2) + " | " + rs.getString(3));
            }
        }
    }
}

Executada com o SQLite, com o driver de "Dados que Sobrevivem" no classpath, primeiro com um banco que ainda não existe e depois de novo com o mesmo banco:

$ java --enable-native-access=ALL-UNNAMED -cp sqlite-jdbc-3.53.2.1.jar MiniFlyway.java banco.db migracoes
migrando banco.db:
  aplicada: V1 esquema inicial
  aplicada: V2 altitude e data de cadastro
  aplicada: V3 trilhas iniciais
  histórico: V1 | esquema inicial | 130c4ea4
  histórico: V2 | altitude e data de cadastro | 54dfc996
  histórico: V3 | trilhas iniciais | 0c824f65

$ java --enable-native-access=ALL-UNNAMED -cp sqlite-jdbc-3.53.2.1.jar MiniFlyway.java banco.db migracoes
migrando banco.db:
  histórico: V1 | esquema inicial | 130c4ea4
  histórico: V2 | altitude e data de cadastro | 54dfc996
  histórico: V3 | trilhas iniciais | 0c824f65

Na segunda execução nada acontece, porque tudo já foi aplicado. É isso que torna seguro chamar o migrador a cada inicialização. Quando o projeto ganha uma quarta migração, V4__indice_por_regiao.sql, com um CREATE INDEX, só ela é aplicada:

migrando banco.db:
  aplicada: V4 indice por regiao
  histórico: V1 | esquema inicial | 130c4ea4
  ...
  histórico: V4 | indice por regiao | f0861444

As duas proteções da miniatura aparecem quando alguém faz algo errado. Primeiro, editar um script já aplicado. Alguém "corrige" a V2, trocando > 0 por >= 0:

Exception in thread "main" java.lang.IllegalStateException: V2 foi alterada depois de aplicada: checksum 54dfc996 no banco, d17d6e17 no arquivo

O checksum do arquivo não bate com o que ficou gravado, e o migrador se recusa a continuar. Sem essa verificação, os bancos que já receberam a V2 antiga e os bancos novos, que receberiam a V2 editada, teriam esquemas diferentes, sem nenhum sinal disso. A regra das migrações é que um script aplicado nunca muda. Uma correção vira uma migração nova.

Segundo, uma migração que falha no meio. Uma V5 acrescenta uma coluna, preenche-a e, no terceiro comando, viola uma restrição:

ALTER TABLE trilha ADD COLUMN acessivel INTEGER NOT NULL DEFAULT 0;
UPDATE trilha SET acessivel = 1 WHERE dificuldade = 'FACIL';
UPDATE trilha SET distancia_km = -1 WHERE nome = 'Praia do Saquinho';
Exception in thread "main" java.sql.SQLException: falha em V5 coluna com erro: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (CHECK constraint failed: distancia_km > 0)

$ sqlite3 banco.db "SELECT group_concat(name, ', ') FROM pragma_table_info('trilha'); SELECT group_concat(versao) FROM historico_do_esquema;"
id, nome, regiao, distancia_km, dificuldade, duracao_min, altitude_max_m, cadastrada_em
1,2,3,4

A coluna acessivel, criada pelo primeiro comando, não ficou no banco, e a V5 não entrou no histórico. A migração inteira rodou numa transação e foi desfeita. Isso só é possível porque o SQLite, assim como o PostgreSQL, trata os comandos que alteram o esquema (a DDL, Data Definition Language) como qualquer outro comando dentro de uma transação. Nem todo banco faz isso, e "MySQL" vai mostrar o que acontece quando não faz.

O Flyway de verdade

O Flyway faz o mesmo com muito mais cuidado. Ele lê scripts SQL com comentários, textos com ponto e vírgula e blocos de procedimentos. Aceita migrações escritas em Java, para transformações de dados complexas demais para SQL, e migrações repetíveis, com prefixo R__, reaplicadas sempre que o conteúdo muda, úteis para visões e funções. Tem comandos para inspecionar e corrigir o histórico: info mostra o estado de cada versão, validate confere os checksums e repair corrige o histórico depois de uma falha. E sabe lidar com as particularidades de dezenas de bancos. Sua tabela de histórico se chama flyway_schema_history e guarda, além da versão e do checksum, quem aplicou, quando, quanto tempo levou e se deu certo.

No Spring Boot 4, o Flyway entra por um starter próprio:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>

Nas versões anteriores ao Spring Boot 4, bastava a dependência flyway-core. Desde o Flyway 10, o suporte a cada banco servidor fica num módulo separado, como flyway-database-postgresql, mas o SQLite continua no núcleo. Os scripts vão para src/main/resources/db/migration, e o Spring Boot executa as migrações pendentes na inicialização, antes de o Hibernate começar a trabalhar. No application.properties, o spring.sql.init.mode e o schema.sql de "Spring Boot pela Primeira Vez" saem, e o Hibernate passa a validar o esquema:

# O esquema é das migrações do Flyway; o Hibernate só confere se as entidades batem com ele
spring.jpa.hibernate.ddl-auto=validate

Com validate, o Hibernate compara cada entidade com as tabelas na inicialização, e uma coluna que falta ou um tipo incompatível impede a aplicação de subir, com uma mensagem que aponta a coluna. É a verificação de contrato de "Contratos à Vista" entre o código e o banco. Com o SQLite, cujos tipos são pouco rigorosos, essa validação pode reclamar de tipos que funcionam na prática. Com o PostgreSQL, na próxima aula, ela funciona como deve.

Para a V2, a entidade Trilha ganha os dois campos novos:

@Column(name = "altitude_max_m")
private Integer altitudeMaxM;                     // Integer, e não int: a coluna aceita nulo

@Convert(converter = DataIsoConverter.class)
@Column(name = "cadastrada_em", nullable = false)
private LocalDate cadastradaEm = LocalDate.now();

O tipo Integer em vez de int é obrigatório aqui. Um int não pode ser null, e uma trilha sem altitude seria lida como altitude zero, um valor que o CHECK da coluna nem permite.

Três regras práticas acompanham o Flyway em qualquer projeto. Nunca edite uma migração aplicada: corrija com uma nova. Não use o ddl-auto=update junto com migrações, porque o Hibernate e o Flyway disputariam o mesmo esquema. E pense nos dados existentes a cada mudança: renomear uma coluna num banco com milhões de linhas e uma aplicação no ar exige etapas (criar a coluna nova, copiar, passar a usar, apagar a antiga), porque durante a implantação a versão antiga e a nova da aplicação podem estar rodando ao mesmo tempo. Por segurança, o comando clean, que apaga o banco inteiro, vem desativado por padrão desde o Flyway 9.

Repositórios em miniatura

O segundo problema é a repetição dos repositórios. Olhando para o RepositorioDeTrilhas de "Objetos no Banco", a maior parte dos métodos segue um padrão: buscar por um campo, ordenar por outro, contar, verificar se existe. O Spring Data parte dessa observação e gera a implementação a partir da interface, deduzindo a consulta até do nome do método. Parece mágica, e é reflexão. A miniatura abaixo usa o java.lang.reflect.Proxy, que cria em tempo de execução um objeto que implementa uma interface qualquer e envia cada chamada a um único tratador. É o mesmo mecanismo dos proxies de transação de "Spring Boot pela Primeira Vez":

import java.lang.reflect.*;
import java.sql.*;
import java.util.*;
import java.util.function.Supplier;
import java.util.regex.*;

/**
 * Repositórios gerados a partir de interfaces, em miniatura: o nome do método vira SQL.
 * findByRegiaoOrderByNome(String) → SELECT ... FROM trilha WHERE regiao = ? ORDER BY nome
 */
public final class MiniRepositorios {
    private static final Pattern METODO = Pattern.compile("(find|count|exists)By(\\w+?)(?:OrderBy(\\w+?)(Desc)?)?");

    /** Cria um objeto que implementa a interface, montando e executando o SQL de cada método chamado. */
    @SuppressWarnings("unchecked")
    public static <R> R criar(Class<R> interfaceDoRepositorio, String tabela, Supplier<Connection> conexoes) {
        InvocationHandler tratador = (proxy, metodo, args) -> {
            switch (metodo.getName()) {                  // os métodos de Object não viram SQL
                case "toString": return "Repositório de " + tabela;
                case "hashCode": return System.identityHashCode(proxy);
                case "equals": return proxy == args[0];
            }
            Matcher m = METODO.matcher(metodo.getName());
            if (!m.matches()) throw new UnsupportedOperationException("não sei derivar SQL de " + metodo.getName());
            List<String> condicoes = Arrays.stream(m.group(2).split("And")).map(p -> coluna(p) + " = ?").toList();
            String onde = " WHERE " + String.join(" AND ", condicoes);
            String ordem = m.group(3) == null ? "" : " ORDER BY " + coluna(m.group(3)) + (m.group(4) != null ? " DESC" : "");
            String sql = switch (m.group(1)) {
                case "count" -> "SELECT COUNT(*) FROM " + tabela + onde;
                case "exists" -> "SELECT EXISTS (SELECT 1 FROM " + tabela + onde + ")";
                default -> "SELECT * FROM " + tabela + onde + ordem;
            };
            System.out.println("  " + metodo.getName() + " → " + sql);
            try (Connection c = conexoes.get(); PreparedStatement ps = c.prepareStatement(sql)) {
                for (int i = 0; i < condicoes.size(); i++) ps.setObject(i + 1, args[i]);
                try (ResultSet rs = ps.executeQuery()) {
                    return switch (m.group(1)) {
                        case "count" -> { rs.next(); yield rs.getLong(1); }
                        case "exists" -> { rs.next(); yield rs.getBoolean(1); }
                        default -> lerLista(rs, elementoDaLista(metodo));
                    };
                }
            }
        };
        return (R) Proxy.newProxyInstance(interfaceDoRepositorio.getClassLoader(), new Class<?>[]{interfaceDoRepositorio}, tratador);
    }

    /** "DistanciaKm" → "distancia_km" */
    private static String coluna(String propriedade) {
        return propriedade.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
    }

    private static Class<?> elementoDaLista(Method metodo) {
        return (Class<?>) ((ParameterizedType) metodo.getGenericReturnType()).getActualTypeArguments()[0];
    }

    /** Monta cada linha num record, casando os componentes com as colunas de mesmo nome. */
    private static List<Object> lerLista(ResultSet rs, Class<?> record) throws Exception {
        RecordComponent[] componentes = record.getRecordComponents();
        Class<?>[] tipos = Arrays.stream(componentes).map(RecordComponent::getType).toArray(Class<?>[]::new);
        Constructor<?> construtor = record.getDeclaredConstructor(tipos);
        List<Object> resultado = new ArrayList<>();
        while (rs.next()) {
            Object[] valores = new Object[componentes.length];
            for (int i = 0; i < componentes.length; i++) {
                Class<?> tipo = java.lang.invoke.MethodType.methodType(tipos[i]).wrap().returnType();   // long → Long
                valores[i] = rs.getObject(coluna(componentes[i].getName()), tipo);
            }
            resultado.add(construtor.newInstance(valores));
        }
        return resultado;
    }
}

O tratador divide o nome do método com uma expressão regular. O prefixo (find, count ou exists) decide o tipo de consulta, as propriedades depois do By, separadas por And, viram condições, e o OrderBy vira a ordenação. Os nomes em camelCase são convertidos para as colunas em snake_case. Os resultados de find são montados em records, usando a reflexão sobre records de "O Programa que se Examina" para encontrar os componentes e o construtor canônico. O getGenericReturnType recupera o tipo dos elementos da lista, List<ResumoDeTrilha>, que o type erasure de "O Preço da Compatibilidade" apaga dos objetos mas preserva nas assinaturas dos métodos.

Uma interface, e nenhuma implementação:

record ResumoDeTrilha(long id, String nome, String regiao, double distanciaKm) {}

interface RepositorioDeTrilhas {
    List<ResumoDeTrilha> findByRegiaoOrderByNome(String regiao);
    List<ResumoDeTrilha> findByRegiaoAndDificuldadeOrderByDistanciaKmDesc(String regiao, String dificuldade);
    long countByRegiao(String regiao);
    boolean existsByNome(String nome);
}

RepositorioDeTrilhas repo = MiniRepositorios.criar(RepositorioDeTrilhas.class, "trilha", () -> {
    try { return DriverManager.getConnection("jdbc:sqlite:banco.db"); }
    catch (SQLException e) { throw new IllegalStateException(e); }
});
System.out.println(repo);
System.out.println(repo.findByRegiaoOrderByNome("Sul"));
System.out.println(repo.findByRegiaoAndDificuldadeOrderByDistanciaKmDesc("Sul", "FACIL"));
System.out.println(repo.countByRegiao("Sul"));
System.out.println(repo.existsByNome("Morro da Coroa"));
System.out.println("classe do objeto: " + repo.getClass().getName());

Sobre o banco migrado acima:

Repositório de trilha
  findByRegiaoOrderByNome → SELECT * FROM trilha WHERE regiao = ? ORDER BY nome
[ResumoDeTrilha[id=1, nome=Lagoinha do Leste, regiao=Sul, distanciaKm=4.5], ResumoDeTrilha[id=2, nome=Praia do Saquinho, regiao=Sul, distanciaKm=2.1]]
  findByRegiaoAndDificuldadeOrderByDistanciaKmDesc → SELECT * FROM trilha WHERE regiao = ? AND dificuldade = ? ORDER BY distancia_km DESC
[ResumoDeTrilha[id=2, nome=Praia do Saquinho, regiao=Sul, distanciaKm=2.1]]
  countByRegiao → SELECT COUNT(*) FROM trilha WHERE regiao = ?
2
  existsByNome → SELECT EXISTS (SELECT 1 FROM trilha WHERE nome = ?)
false
classe do objeto: $Proxy0

Quatro métodos que ninguém implementou, cada um executando o SQL que o nome descreve, com parâmetros por marcadores e resultados em records. A classe do objeto, $Proxy0, foi gerada pela JVM durante a execução.

Spring Data JPA

O Spring Data JPA, criado em 2011 e parte da família Spring Data, que também cobre o MongoDB da aula "MongoDB", faz o mesmo com o JPA por baixo. Ele entende dezenas de palavras-chave nos nomes dos métodos, valida os nomes na inicialização, e não na primeira chamada, e acrescenta paginação, ordenação, projeções e consultas escritas à mão. O repositório das trilhas vira uma interface:

package br.com.exemplo.trilhas;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface RepositorioDeTrilhas extends JpaRepository<Trilha, Long> {

    // Consultas derivadas do nome do método
    List<Trilha> findByRegiaoOrderByNome(String regiao);
    Optional<Trilha> findByNome(String nome);
    boolean existsByNome(String nome);
    long countByDificuldade(Dificuldade dificuldade);
    List<Trilha> findByDistanciaKmLessThanEqualAndDificuldadeIn(double km, List<Dificuldade> dificuldades);

    // Paginação: o Pageable diz qual página, de que tamanho e em que ordem
    Page<Trilha> findByRegiao(String regiao, Pageable pagina);

    // Contra o N+1: busca as avaliações junto, numa consulta só
    @EntityGraph(attributePaths = "avaliacoes")
    List<Trilha> findComAvaliacoesByRegiao(String regiao);

    // Quando o nome ficaria ilegível, a JPQL vai explícita
    @Query("""
            SELECT new br.com.exemplo.trilhas.ResumoDeTrilha(t.nome, COUNT(a), AVG(a.nota))
              FROM Trilha t LEFT JOIN t.avaliacoes a
             GROUP BY t.id, t.nome
             ORDER BY t.nome
            """)
    List<ResumoDeTrilha> resumos();
}

Não há @Repository nem implementação. O Spring Data encontra as interfaces que estendem suas interfaces-base e registra um proxy para cada uma no contêiner. O JpaRepository<Trilha, Long>, parametrizado com a entidade e o tipo do id pelos genéricos de "Escreva uma Vez para Todo Tipo", traz de graça os métodos comuns: save, findById, findAll, deleteById, count e vários outros.

As consultas derivadas vão muito além da igualdade. LessThanEqual, GreaterThan, Between, In, Like, Containing, StartingWith, IsNull, True, IgnoreCase e outras palavras-chave estão na documentação do Spring Data. Nomes longos demais, porém, viram um problema de legibilidade: findByDistanciaKmLessThanEqualAndDificuldadeIn já está no limite. A partir daí, o @Query com JPQL explícita é mais honesto. As palavras entre find e By, como o Com Avaliacoes de findComAvaliacoesByRegiao, são ignoradas pelo analisador e servem só para dar nome ao método. A anotação @EntityGraph é a solução do Spring Data para o problema N+1 de "Objetos no Banco": ela faz as avaliações serem buscadas na mesma consulta, com um JOIN.

A paginação merece atenção, porque qualquer listagem pública precisa dela: ninguém quer devolver cem mil trilhas numa resposta. O serviço passa um Pageable:

Page<Trilha> pagina = repositorio.findByRegiao("Sul", PageRequest.of(0, 20, Sort.by("nome")));
pagina.getContent();         // as até 20 trilhas desta página
pagina.getTotalElements();   // quantas existem ao todo (uma segunda consulta, com COUNT)
pagina.getTotalPages();

O Spring Data gera a consulta com o limite e o deslocamento na sintaxe do banco em uso e, para o Page, uma segunda consulta com COUNT para o total. Quando o total não é necessário, como numa rolagem infinita, o tipo de retorno Slice evita essa segunda consulta.

O serviço de "Objetos no Banco" continua quase igual, trocando o repositório escrito à mão pela interface. O método save merece um cuidado: ele chama persist se a entidade for nova (com id nulo) e merge se não for. Com a verificação de sujeira, alterar uma entidade gerenciada dentro de uma transação já basta, e chamar save depois de mudarDuracao é inofensivo, mas desnecessário.

Vale saber que o Spring Data não é mais a única opção desse tipo. A Jakarta EE 11 trouxe o Jakarta Data, uma especificação com a mesma ideia de repositórios gerados a partir de interfaces, implementada por projetos como o Hibernate. Os estilos são parecidos, e quem aprende um reconhece o outro.

Testando com as migrações

O teste de "Objetos no Banco", com @DataJpaTest, continua funcionando com duas mudanças. O @Import do repositório sai, porque as interfaces do Spring Data são encontradas automaticamente. E a propriedade spring.sql.init.mode também sai, porque o Flyway, incluído pelo @DataJpaTest, aplica as migrações no banco temporário antes dos testes. Esse detalhe tem valor próprio: cada execução da suíte de testes prova que as migrações, aplicadas do zero, produzem um esquema que o Hibernate aceita. Um teste novo exercita as consultas derivadas:

@Test
void consultasDerivadas() {
    repositorio.save(new Trilha("Morro da Coroa", "Leste", 6.8, Dificuldade.DIFICIL, Duration.ofMinutes(210), List.of()));
    repositorio.save(new Trilha("Costão do Santinho", "Norte", 3.2, Dificuldade.FACIL, Duration.ofMinutes(90), List.of()));

    assertThat(repositorio.existsByNome("Morro da Coroa")).isTrue();
    assertThat(repositorio.countByDificuldade(Dificuldade.FACIL)).isEqualTo(2);   // + Praia do Saquinho, da V3
    assertThat(repositorio.findByDistanciaKmLessThanEqualAndDificuldadeIn(3.5, List.of(Dificuldade.FACIL)))
            .extracting(Trilha::getNome)
            .containsExactlyInAnyOrder("Costão do Santinho", "Praia do Saquinho");
}

O teste conta com as trilhas da V3, e isso é deliberado: as migrações de dados de referência fazem parte do esquema, e os testes as enxergam como a produção enxerga.

Com o Flyway e o Spring Data, o projeto das Trilhas da Ilha tem a infraestrutura que um sistema real precisa: um esquema que evolui com segurança, versão a versão, e repositórios que custam uma linha por consulta. O banco, porém, ainda é um arquivo SQLite, que aceita uma escrita por vez e não tem muitos dos recursos que tornam os bancos servidores tão poderosos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega percebeu um erro de digitação num comentário da V1__esquema_inicial.sql, que já foi aplicada em produção, e o corrigiu. O que acontece na próxima implantação, com a miniatura e com o Flyway real? Qual seria a forma certa de lidar com isso?

Ver resposta

✓ Resposta: O checksum é calculado sobre o conteúdo do arquivo, e um comentário faz parte do conteúdo. Na miniatura, a mudança de um único caractere altera o SHA-256, e a próxima execução falha com "V1 foi alterada depois de aplicada". O Flyway real também recusa: o validate, executado automaticamente antes do migrate, detecta a divergência entre o checksum do arquivo e o da tabela flyway_schema_history, e a aplicação não sobe. Isso parece exagero para um comentário, mas o migrador não tem como distinguir um comentário de uma restrição alterada, e qualquer tolerância abriria espaço para mudanças silenciosas. O certo é não editar a migração aplicada e deixar o erro de digitação onde está. Se a correção for realmente importante, e o conteúdo efetivo não mudou, o Flyway tem o comando repair, que atualiza os checksums gravados para os dos arquivos atuais. Ele deve ser usado com cuidado, por alguém que conferiu que a mudança é inofensiva, e em todos os ambientes. Mudanças que alteram o esquema nunca são resolvidas com repair, e sim com uma migração nova.

Exercício 2

Escreva a migração que renomeia a coluna regiao para zona sem quebrar a aplicação que está no ar, sabendo que durante a implantação a versão antiga e a nova da aplicação rodam ao mesmo tempo. Por que um simples ALTER TABLE trilha RENAME COLUMN regiao TO zona não serve?

Ver resposta

✓ Resposta: Durante uma implantação sem parada, as instâncias antigas continuam atendendo requisições enquanto as novas sobem. Se a migração renomear a coluna de uma vez, as instâncias antigas passam a falhar em toda consulta que usa regiao até serem desligadas, e se a implantação precisar ser desfeita, a versão antiga não funciona mais com o banco. A técnica é dividir a mudança em etapas, cada uma compatível com as duas versões do código, conhecida como expandir e contrair. Primeiro, uma migração que expande:

-- V6__zona_nova_coluna.sql
ALTER TABLE trilha ADD COLUMN zona TEXT;
UPDATE trilha SET zona = regiao;

A versão seguinte da aplicação grava nas duas colunas e lê de zona. A versão antiga continua usando regiao, que segue preenchida. Depois que todas as instâncias estão na versão nova, e nenhuma volta será necessária, uma migração tornaria zona obrigatória. No SQLite, isso exige recriar a tabela; no PostgreSQL, basta um ALTER COLUMN zona SET NOT NULL. Por fim, uma versão que só usa zona e uma migração que contrai, apagando regiao:

-- V7__remove_regiao.sql
ALTER TABLE trilha DROP COLUMN regiao;

São três implantações em vez de uma, e é o preço de nunca deixar o sistema fora do ar. Para sistemas que podem parar por alguns minutos numa madrugada, o RENAME COLUMN direto, com as instâncias desligadas, é aceitável.

Exercício 3

A miniatura divide o nome do método com split("And"). Mostre um nome de propriedade que quebra essa lógica e explique como o Spring Data evita o problema.

Ver resposta

✓ Resposta: Uma propriedade cujo nome contém "And" com maiúscula, como grauDeAndamento ou nivelAndino. O método findByNivelAndino(String) seria dividido em ["Nivel", "ino"], gerando WHERE nivel = ? AND ino = ?, com duas condições para um único argumento, e a consulta falharia já no prepareStatement, com "no such column: nivel", embrulhada numa UndeclaredThrowableException, porque o método da interface não declara SQLException. Se as duas colunas existissem, seria pior: o laço do setObject pediria um segundo argumento que não foi passado. A miniatura trata o nome como texto, sem saber quais propriedades a entidade tem. O Spring Data resolve isso consultando o modelo da entidade: ele conhece os nomes das propriedades pela reflexão e pelos metadados do JPA, e tenta interpretar o nome do método casando trechos com propriedades existentes. Quando há ambiguidade, a documentação permite usar o sublinhado como separador explícito de caminho de propriedades. E como os nomes são analisados na inicialização da aplicação, um nome que não pode ser interpretado impede a aplicação de subir, com uma mensagem que diz qual propriedade não foi encontrada, em vez de falhar na primeira chamada em produção. É a mesma estratégia de falhar cedo do Padrao.de do Farol, que validava as rotas no registro.

Exercício 4

O findByRegiao(String, Pageable) devolve um Page, que executa duas consultas. Numa tela de rolagem infinita, o usuário só precisa saber se há mais itens. Que tipo de retorno usar, como ele sabe se há uma próxima página sem contar tudo, e por que isso importa numa tabela grande?

Ver resposta

✓ Resposta: O tipo certo é o Slice<Trilha>. O Page executa a consulta da página e uma segunda com COUNT(*) sobre o mesmo filtro, para informar o total de elementos e de páginas. O Slice dispensa a contagem e, para saber se há uma próxima página, pede ao banco um elemento a mais do que o tamanho da página: se vieram 21 linhas para uma página de 20, há mais, e o método hasNext() devolve true, sendo a vigésima primeira linha descartada. Numa tabela pequena, a contagem é barata. Numa tabela com milhões de linhas e um filtro sem índice que o favoreça, o COUNT(*) precisa percorrer todas as linhas que satisfazem o filtro, e pode custar mais do que a própria página, a cada rolagem. Muitas interfaces não precisam do total exato, e é comum mostrar "mais de 1000 resultados" em vez de um número preciso justamente por isso. A escolha entre Page e Slice é, de novo, deixar a forma dos dados seguir o caso de uso.

Exercício 5

Por que as migrações de dados de referência, como a V3, devem estar no Flyway e não num ApplicationRunner como o de "Spring Boot pela Primeira Vez"? E que tipo de dado não deve ir para uma migração?

Ver resposta

✓ Resposta: Uma migração é aplicada uma vez por banco, em ordem, registrada no histórico e protegida por checksum. Um ApplicationRunner roda a cada inicialização e precisa decidir sozinho se os dados já existem, com verificações como a do "banco vazio", que falham de formas sutis: se um usuário apagar uma das trilhas de exemplo, o ApplicationRunner pode recriá-la; se o banco não estiver vazio por outro motivo, nunca a cria. Com a migração, os dados de referência passam a fazer parte da definição do esquema: todo banco criado do zero os recebe, os testes os enxergam, e mudanças neles viram novas migrações, versionadas e revisáveis. Os dados que pertencem às migrações são os de referência, dos quais o sistema depende para funcionar, como listas de regiões, categorias ou configurações iniciais. Não pertencem a elas os dados de uso, criados pelos usuários, nem os dados de exemplo para desenvolvimento, que não devem aparecer em produção, nem dados sensíveis, como senhas e chaves, que acabariam no repositório de código. Os exemplos de desenvolvimento continuam bem servidos por um ApplicationRunner ativo só num perfil como dev, ou por migrações num local separado, configurado apenas para esse perfil.

Comentários

Mais em Java

Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma
Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma

Nesta aula você desenha uma fase no formato do editor Tiled, carrega esse mapa…

O Preço da Compatibilidade — Wildcards e Type Erasure
O Preço da Compatibilidade — Wildcards e Type Erasure

Wildcards e type erasure em Java: por que os genéricos são invariantes e os…

Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes
Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Quando o synchronized pesa demais, o java.util.concurrent oferece peças sob…