Trabalho Final T21 Fundatec Detalhamento Parte 1

Nesse post vamos detalhar alguns aspectos do trabalho final requirido na disciplina de LPII para fundatec de Porto Alegre, no curso técnico em informática.

Esse post visa facilitar a elaboração do trabalho criando-se uma documentação que detalhe aspectos do desenvolvimento que serão reforçados em sala de aula.

Esse post não tem o intuito de ser um guia completo e substituto para as aulas

Vamos começar com a base feita em sala de aula disponível em giovannicandido/fundatec-trabalho-final (github.com)

Nessa base temos 3 modelos do nosso banco: Cliente, Endereco, Plano

Esses constituem o cadastro básico de clientes.

Falta: Tarifa, Veiculo, TarifaPorTipo, Estacionamento e TipoVeiculo

Vamos ver como persistir o cadastro básico de clientes primeiro. Para os demais itens teremos uma mistura de CRUD com logica de negócio, pois a tarifa é calculada.

Nossa logica para persistir esses itens pode ser feita de duas formas:

  1. Primeiro se cria um endereço, depois se cria o cliente passando o id do endereço

  2. Persiste o cliente com o objeto endereço

Na primeira abordagem um frontend teria um cadastro de endereços separado do de cliente, na segunda o cadastro do endereço pode ser feito junto ao cliente. Já que o endereço tem uma relação UmParaUm com cliente qualquer uma das abordagens é correta, mas a segunda tem a caracteristica de acoplar o ciclo de vida do endereço ao do cliente, ou seja, quando atualizarmos um cliente temos que atualizar seu endereço (se não usarmos atualização parcial de objeto)

Vamos abordar a primeira opção.

Primeiro criamos o endereço. É a operação mais simples já que não envolve mais de uma entidade:

@RestController
@RequestMapping(path = "/endereco")
public class EnderecoCtrl {
    private final EnderecoService service;

    public EnderecoCtrl(EnderecoService service) {
        this.service = service;
    }

    @PostMapping
    public ResponseEntity create(@RequestBody Endereco endereco) {
        service.create(endereco);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
@Service
public class EnderecoService implements CrudService<Endereco> {
    private final EnderecoRepository enderecoRepository;

    public EnderecoService(EnderecoRepository enderecoRepository) {
        this.enderecoRepository = enderecoRepository;
    }

    @Override
    public Endereco create(Endereco entity) {
        return this.enderecoRepository.save(entity);
    }

    @Override
    public Endereco findById(Long idEndereco) {
        return this.enderecoRepository.findById(idEndereco).orElse(null);
    }

    @Override
    public Endereco update(Endereco entity) {
        if (entity.getId() == null) {
            throw new RuntimeException("Entidade precisa de um id para atualizar");
        }
        return enderecoRepository.save(entity);
    }
}
public interface EnderecoRepository extends JpaRepository<Endereco, Long> {
}

Vemos que o service de Endereço apenas salva a entidade endereço, usando-se o repository. Por enquanto não usamos DTO passamos diretamente a entidade entre as camadas.

O Controller também é bem simples.

Agora para persistir um cliente é necessário 3 momentos:

  1. Procurar o id do endereço passado no banco de dados. Caso não exista temos que parar o fluxo retornando alguma informação a quem chamou a API

  2. Persistir o cliente

  3. Atualizar o endereço com o ID do cliente.

Note que o Service e Repository para Cliente é basicamente identico ao de Endereço:

public interface ClienteRepository extends JpaRepository<Cliente, Long> {
}
@Service
public class ClienteService implements CrudService<Cliente> {
    private final ClienteRepository repository;

    public ClienteService(ClienteRepository repository) {
        this.repository = repository;
    }

    @Override
    public Cliente create(Cliente entity) {
        return this.repository.save(entity);
    }

    @Override
    public Cliente findById(Long idEndereco) {
        return repository.getById(idEndereco);
    }

    @Override
    public Cliente update(Cliente entity) {
        if (entity.getId() == null) {
            throw new RuntimeException("Entidade precisa de um id para atualizar");
        }
        return repository.save(entity);
    }
}
@RequestMapping("/cliente")
public class ClienteCtrl {
    private final ClienteService service;
    private final EnderecoService enderecoService;

    public ClienteCtrl(ClienteService service, EnderecoService enderecoService) {
        this.service = service;
        this.enderecoService = enderecoService;
    }

    @PostMapping
    public ResponseEntity create(@RequestBody ClienteDTO clienteDTO) {
        Endereco endereco = enderecoService.findById(clienteDTO.getIdEndereco());
        if (endereco == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body("Não foi encontrado o endereco de id " + clienteDTO.getIdEndereco());
        }

        Cliente cliente = new Cliente();
        cliente.setCpf(clienteDTO.getCpf());
        cliente.setEndereco(endereco);
        // todo setar plano

        service.create(cliente);

        // Atualiza endereco apontando para cliente
        endereco.setCliente(cliente);
        enderecoService.update(endereco);

        return ResponseEntity.status(HttpStatus.CREATED).build();

    }
}

A logica descrita nos passos anteriores foi colocada no controlador. É uma boa ideia adicioná-la ao service, o service de Cliente trabalharia com o DTO ao inves da entidade, então o generic mudaria.

Note que para que o id do cliente seja associado ao endereço é necessário atualizar a entidade endereço também.

Para atualizar um cliente a logica é bem parecida com o criar um cliente. Bastando primeiro procurar o cliente pelo seu id no banco, setar as informações e salvar o cliente.

 /**
     * Atualizar um cliente no banco de dados
     * @param clienteDTO Informações a atualizar
     * @param id Id do cliente a ser atualizado passado na URL
     * @return OK se atualizado com sucesso
     */
    @PutMapping("/{id}")
    public ResponseEntity update(@RequestBody ClienteDTO clienteDTO,
                                 @PathVariable("id") Long id) {
        Endereco endereco = enderecoService.findById(clienteDTO.getIdEndereco());
        if (endereco == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body("Não foi encontrado o endereco de id " + clienteDTO.getIdEndereco());
        }

        Cliente cliente = service.findById(id);

        if (cliente == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body("Não foi encontrado o cliente de id " + id);
        }
        // atualiza informações
        cliente.setCpf(clienteDTO.getCpf());

        // atualiza no banco
        service.update(cliente);
        // atualiza o endereço
        endereco.setCliente(cliente);
        enderecoService.update(endereco);
        return ResponseEntity.status(HttpStatus.OK).build();
    }

Não será detalhado o codigo para o delete nem para o update do endereço, mas seguem a mesma lógica acima. (Lembre-se o delete busca pelo id na URL)

Entidade Plano

A entidade plano possui uma api ligeiramente diferente das vistas até agora pois:

  1. Um plano só existe com um cliente associado

  2. Podemos considerar que um plano não pode ser deletado pois ele possui créditos que seriam perdidos

  3. Trocar o assinate do plano NÃO deve ser possível, então atualizar o plano também não faz sentido.

  4. O plano pode ser recarregado (um de nossos requisitos)

Com base nesses requisitos podemos modelar a API do plano da seguinte forma:

Criar um plano novo com uma carga inicial:

Recarrega um plano adicionando-se um valor ao mesmo:

Obtem informações de um plano de um cliente. O id passado no final da URL é o id do cliente:

Com base nas informações já passadas é possível criar essas 3 API

Notas:

  1. Ao criar um novo plano valide se já não existe um para o cliente, caso contrario vai atualizar o valor e o cliente perderá créditos.

  2. O mesmo DTO para recarga pode ser usado para criação de novo plano

  3. Você não precisa atualizar o cliente com o plano igual feito ao salvar um endereço no cliente, pois está alterando o plano que possui a foreing key para o cliente.

  4. Nas 3 API você precisará de um método no repository para retornar o plano dado o id do cliente. O método que faz essa query está abaixo em duas opções:

public interface PlanoRepository extends JpaRepository<Plano, Long> {
    Plano findByAssinante(Cliente assinante);
    @Query("select c from Plano c join c.assinante a where a.id = :id")
    Plano findByAssinanteId(@Param("id") Long id);
}

Na primeira opção se passa o cliente como parametro e o spring vai criar a query com base no nome, ou seja um where com filtro by Assinante sendo Assinante o nome da propriedade na entidade.

Na segunda opção ao invez de passar o cliente completo, passamos o id e criamos uma query customizada com a anotação @Query que filtra os planos cujo assinante tem o id passado como parametro. O parametro é o :id que foi explicitado pela anotação @Param

Importante: Se você retornar a entidade Plano diretamente no controlador vai ter o seguinte erro:

java.lang.StackOverflowError: null
    at java.base/java.lang.ClassLoader.defineClass1(Native Method) ~[na:na]
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017) ~[na:na]
    at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:174) ~[na:na]
    at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:800) ~[na:na]
    at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:698) ~[na:na]
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:621) ~[na:na]
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:579) ~[na:na]
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178) ~[na:na]
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522) ~[na:na]
    at com.fasterxml.jackson.databind.JsonMappingException.prependPath(JsonMappingException.java:445) ~[jackson-databind-2.13.4.2.jar:2.13.4.2]

Isso acontece porque repare que o Plano possui um Cliente, e um Cliente possui um plano (bidirecional). O Jackson (biblioteca JSON) vai tentar carregar o cliente do plano de id 1 por exemplo, quando ele carregar o cliente pelo hibernate, ele vai ver que tem um plano dentro do cliente e vai tentar carregá-lo novamente, assim achando o mesmo plano que por sua vez carrega o cliente novamente, e assim sucessivamente até a memória estourar.

Para solucionar o problema a melhor maneira é retornar um DTO com os dados. Exemplo:

public PlanoDTO findByClienteId(@PathVariable Long id) {
        Plano plano = planoRepository.findByAssinanteId(id);
        if (plano == null) {
            return null;
        }
        PlanoDTO dto = new PlanoDTO();
        dto.setValor(plano.getValor());
        ClienteDTO clienteDTO = new ClienteDTO();
        clienteDTO.setCpf(plano.getAssinante().getCpf());
        clienteDTO.setIdEndereco(plano.getAssinante().getEndereco().getId());
        dto.setCliente(clienteDTO);
        return dto;
    }

Repare que retornamos a seguinte informação:

{
    "valor": 10.0,
    "cliente": {
        "cpf": "011100003",
        "idEndereco": 1
    }
}

Mas a pesquisa foi no banco 3 vezes:

Hibernate: select plano0_.id as id1_2_, plano0_.cliente_id as cliente_3_2_, plano0_.valor as valor2_2_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id where cliente1_.id=?
Hibernate: select cliente0_.id as id1_0_0_, cliente0_.cpf as cpf2_0_0_, endereco1_.id as id1_1_1_, endereco1_.bairro as bairro2_1_1_, endereco1_.cep as cep3_1_1_, endereco1_.cidade as cidade4_1_1_, endereco1_.cliente_endereco as cliente_8_1_1_, endereco1_.estado as estado5_1_1_, endereco1_.numero as numero6_1_1_, endereco1_.rua as rua7_1_1_, plano2_.id as id1_2_2_, plano2_.cliente_id as cliente_3_2_2_, plano2_.valor as valor2_2_2_ from cliente cliente0_ left outer join endereco endereco1_ on cliente0_.id=endereco1_.cliente_endereco left outer join plano plano2_ on cliente0_.id=plano2_.cliente_id where cliente0_.id=?
Hibernate: select plano0_.id as id1_2_2_, plano0_.cliente_id as cliente_3_2_2_, plano0_.valor as valor2_2_2_, cliente1_.id as id1_0_0_, cliente1_.cpf as cpf2_0_0_, endereco2_.id as id1_1_1_, endereco2_.bairro as bairro2_1_1_, endereco2_.cep as cep3_1_1_, endereco2_.cidade as cidade4_1_1_, endereco2_.cliente_endereco as cliente_8_1_1_, endereco2_.estado as estado5_1_1_, endereco2_.numero as numero6_1_1_, endereco2_.rua as rua7_1_1_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id left outer join endereco endereco2_ on cliente1_.id=endereco2_.cliente_endereco where plano0_.cliente_id=?

Isso acontece porque pesquisamos a entidade Plano diretamente, e as associações Cliente e Endereço são pesquisadas juntamente, pois esse é o padrão do @OneToOne

Se quisermos otimizar essa pesquisa podemos fazer o seguinte:

public interface PlanoRepository extends JpaRepository<Plano, Long> {
    Plano findByAssinante(Cliente cliente);
    @Query("select c from Plano c join c.assinante a where a.id = :id")
    Plano findByAssinanteId(@Param("id") Long id);

    @Query("select new br.org.fundatec.tfinal.tfinal.dto.PlanoDTO(c.valor, a.cpf, a.endereco.id) " +
            "from Plano c join c.assinante a where a.id = :id")
    PlanoDTO findPlanoDTOByAssinanteId(@Param("id") Long id);
}

Reparem no seguinte: A query retorna um new criando um dto e usando o construtor do mesmo. Dessa forma o select sabe o que seleciona e cria uma query especifica para ele.

No caso um select apenas é feito no banco de dados:

Hibernate: select plano0_.valor as col_0_0_, cliente1_.cpf as col_1_0_, endereco2_.id as col_2_0_ from plano plano0_ inner join cliente cliente1_ on plano0_.cliente_id=cliente1_.id cross join endereco endereco2_ where cliente1_.id=endereco2_.cliente_endereco and cliente1_.id=?

Isso é muito mais performático.

Restante das classes:

public class PlanoDTO {
    private Double valor;
    private ClienteDTO cliente;

    public PlanoDTO() {

    }

    public PlanoDTO(Double valor, String clientCPF, Long clienteEnderecoId) {
        this.valor = valor;
        this.cliente = new ClienteDTO(clientCPF, clienteEnderecoId);
    }

    public Double getValor() {
        return valor;
    }

    public void setValor(Double valor) {
        this.valor = valor;
    }

    public ClienteDTO getCliente() {
        return cliente;
    }

    public void setCliente(ClienteDTO cliente) {
        this.cliente = cliente;
    }
}
public PlanoDTO findByClienteId(@PathVariable Long id) {
//        Plano plano = planoRepository.findByAssinanteId(id);
//        if (plano == null) {
//            return null;
//        }
//        PlanoDTO dto = new PlanoDTO();
//        dto.setValor(plano.getValor());
//        ClienteDTO clienteDTO = new ClienteDTO();
//        clienteDTO.setCpf(plano.getAssinante().getCpf());
//        clienteDTO.setIdEndereco(plano.getAssinante().getEndereco().getId());
//        dto.setCliente(clienteDTO);
        return planoRepository.findPlanoDTOByAssinanteId(id);
    }

Com isso nós temos 4 requisitos do trabalho modelados:

  1. O sistema deve permitir o cadastro, alteração e consultas nos dados de um assinante;

  2. O cliente pode possuir apenas um plano em vigor

  3. O cliente deve possuir um endereço

  4. O sistema deve permitir que o assinante faça recarga do valor do seu crédito;

    O requisito: "O endereço da pessoa deve estar completo". É fácil de fazer basta colocar todos os dados de endereço com exceção de complemento como obrigatórios. Isso pode ser feito no banco de dados e na validação do controlador

A api do trabalho final está no git repository

Num próximo artigo vamos modelar as outras entidades e falar de outros requisitos da aplicação