Skip to main content

Command Palette

Search for a command to run...

Trabalho Final T21 Fundatec Detalhamento Parte 1

Updated
9 min readView as Markdown

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