Solucionado (ver solução)
Solucionado
(ver solução)
5
respostas

Binding em relacionamentos Many to Many

Boa noite,

Eu estou criando um formulário de cadastro de Transações e essas transações possuem uma lista de categorias,

                <form:select path="tra_categories"  multiple="true">
                    <form:options items="${listCategory}" itemLabel="cat_name" itemValue="cat_id"/>
                    <form:errors path="tra_categories" />
                </form:select>

Porém, o spring não está mapeando automaticamente como os outros atributos, já tentei colocar no item value a categoria e o id.

 [Field error in object 'transaction' on field 'tra_categories': rejected value [2,3]; codes [typeMismatch.transaction.tra_categories,typeMismatch.tra_categories,typeMismatch.java.util.List,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [transaction.tra_categories,tra_categories]; arguments []; default message [tra_categories]]; default message [Failed to convert property value of type 'java.lang.String[]' to required type 'java.util.List' for property 'tra_categories'; nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [br.stralom.moneyspring.entities.Category] for property 'tra_categories[0]': no matching editors or conversion strategy found]]
[Field error in object 'transaction' on field 'tra_categories': rejected value [Category{cat_id=2, cat_name=Teste 2},Category{cat_id=3, cat_name=Teste 3}]; codes [typeMismatch.transaction.tra_categories,typeMismatch.tra_categories,typeMismatch.java.util.List,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [transaction.tra_categories,tra_categories]; arguments []; default message [tra_categories]]; default message [Failed to convert property value of type 'java.lang.String[]' to required type 'java.util.List' for property 'tra_categories'; nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [br.stralom.moneyspring.entities.Category] for property 'tra_categories[0]': no matching editors or conversion strategy found]]

Ele fala que eu preciso ter uma estrategia de conversão ou um editor, como eu faço isso ? Eu ainda não completei o curso de Spring, ele contem algo sobre o assunto ?

5 respostas

Fala Bruno, tudo bem ?

Como está a classe que define seu modelo ? O campo de multi seleção envia um array de String, e o Spring não tem um converter padrão de String[] pra List. Você pode fazer seu modelo receber array de String, mas como no seu caso você quer trabalhar com a List do Java, você pode tentar criar um objeto que representa o form apenas (com String[]) e fazer a transição pra seu objeto de domínio, onde permanece usando List.

Existe a possibilidade de criar um converter como citado. Você pode criar uma classe e implementar a interface Converter do Spring e registrá-lo entre os conversores padrão, mas em geral tem formas mais simples de resolver.

Posta ai pra gente como está o modelo .. Abraço!

Classe Transaction

@Entity
@Table(name="tb_transaction")
public class Transaction implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long tra_id;
    @NotNull
    private String tra_name;
    @NotNull
    private String tra_desc;
    private BigDecimal tra_value;
    @Temporal(TemporalType.DATE)
    @DateTimeFormat(pattern="yyyy-MM-dd")
    private Calendar tra_date = Calendar.getInstance();
    @Enumerated(EnumType.STRING)
    private TypeTransaction tra_typeTransaction;
    private String tra_invoicePath;
    @ManyToOne
    @JoinColumn(name="tra_company")
    private Company tra_company;

    @OneToMany(mappedBy = "ins_transaction")
    private Collection<Installment> tra_installments ;

    @ManyToMany(mappedBy="bal_transactions")
    private Collection<Balance> tra_balances ;

    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable(
        name = "tb_transactions_category",joinColumns=
        {@JoinColumn(name="tra_id")},inverseJoinColumns = 
        {@JoinColumn(name="cat_id")})
    private Set<Category> tra_categories = new HashSet<>();

// getters e setters 
// hash e equals

Classe Categoria

@Entity
@Table(name="tb_category")
public class Category implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long cat_id;

    private String cat_name;
    @ManyToMany(mappedBy="tra_categories")
    private Set<Transaction> cat_transactions = new HashSet<>();

Controller

    @RequestMapping("/form")
    public ModelAndView form(Transaction transaction) {
        ModelAndView modelAndView = new ModelAndView("transactions/form");
        List<Category> listCategory = catDAO.findAll();
        modelAndView.addObject("listCategory", listCategory);
        return modelAndView;
    }
    @RequestMapping( method=RequestMethod.POST)
    public ModelAndView save( MultipartFile invoice, @Valid Transaction transaction,BindingResult result, RedirectAttributes redirectAttributes) {
         // test
        // List<Category> categorias = new ArrayList<>(); 
         //Category category = catDAO.find(Long.parseLong(cat_id));
        //transaction.setTra_categories(categorias);
        //    System.out.println(" ----------------------  :::::" +categoria);
         //  categorias.add(categoria);
        //transaction.setTra_categories(categorias);
        if(result.hasErrors()){
            System.out.println(result.getErrorCount());
            System.out.println(result.getAllErrors());
            return form(transaction);
        }
        System.out.println(invoice.getOriginalFilename());
        String path = fileSaver.write("/archives", invoice);
        transaction.setTra_invoicePath(path);
        traDAO.gravar(transaction);

        redirectAttributes.addFlashAttribute("sucess", "Transação adicionada com sucesso");
        return new ModelAndView("redirect:transactions");

    }

View

        <spring:url value="/transactions" var="transactions"/>
        <form:form action="${transactions}" method="POST" commandName="transaction" enctype="multipart/form-data">
            <div>

                <label for="tra_name">Nome: </label>          
                <form:input path="tra_name" name="tra_name"/>
                <form:errors  path="tra_name"/>
            </div>
            <div>
                <label for="tra_desc">Descrição:</label>  
                <form:textarea cols="20" rows="10" path="tra_desc" name="tra_desc"/>
                <form:errors path="tra_desc" />
            </div>
            <div>
                <label for="tra_value">Valor: </label>    
                <form:input path="tra_value" name="tra_value"/>
                <form:errors path="tra_value" />
            </div>
            <div>
                <label for="tra_date">Data da Transação: </label>
                <form:input path="tra_date" type="date" name="tra_date"/>
                <form:errors path="tra_date" />
            </div>

            <div>
                <label for="tra_typeTransaction">Tipo: </label>
                <form:radiobutton path="tra_typeTransaction" name="typeTransaction" value="ENTRADA"/>Entrada
                <form:radiobutton path="tra_typeTransaction" name="typeTransaction" value="SAIDA" />Saida

            </div>

            <div>
                <label for="tra_invoice">Nota Fiscal: </label>
                <input type="file" name="invoice"/>
            </div>

            <div>
                <form:select path="tra_categories"  multiple="true">
                    <form:options items="${listCategory}" itemLabel="cat_name" />
                    <form:errors path="tra_categories" />
                </form:select>

            </div>
            <button type="submit">Cadastrar Transação</button>
        </form:form>

Fala Bruno, beleza?

Para o spring conseguir fazer o bind da lista no seu objeto primeiro ele tem que ter o objeto vazio em mãos. Então no seu método form do controller você precisa enviar também seu objeto vazio, além disso no seu form:Select você precisa passar o value também .

Vai ficar mais ou menos assim:

@RequestMapping("/form")
    public ModelAndView form(Transaction transaction) {
        ModelAndView modelAndView = new ModelAndView("transactions/form");
        List<Category> listCategory = catDAO.findAll();
        modelAndView.addObject("listCategory", listCategory);
        modelAndView.addObject("transaction", transaction);
        return modelAndView;
    }

Seu form:select

<form:select path="tra_categories"  multiple="true" items="${listCategory}" itemLabel="cat_name" itemValue="cat_id">

Acho que agora vai funcionar

solução!

Fala pessoal ..

Vendo o modelo agora ficou mais claro.

Cuidado com a tag <form:errors> dentro da <form:select> ela não deve estar ali, e sim fora do select. Bem observado pelo Mário a necessidade do itemValue. Uma outra coisa no método form, não é necessário passar o objeto de Transaction por modelAndView.addObject("transaction", transaction);. Só de colocar o parâmetro no método public ModelAndView form(Transaction transaction) {...}o Spring, por padrão, já disponibiliza este objeto no contexto dessa requisição, ou seja, ele está disponível na JSP através de ${transaction}.

Mas isso por si só não resolve o problema. Ele é mais grave aqui. Escrevi um código de teste e quando vamos enviar os valores vamos dar uma olhada:

exemplo de modelos:

public class Transaction {

    private Long id;
    private String name;
    private Set<Category> categories = new HashSet<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Set<Category> getCategories() {
        return categories;
    }

    public void setCategories(Set<Category> categories) {
        this.categories = categories;
    }

    @Override
    public String toString() {
        return "Transaction{id:" + id + ", name:" + name + ", categories: " + categories + "}";
    }
}
public class Category {

    private Long id;
    private String name;

    /**
     * @deprecated
     */
    public Category() {
    }

    public Category(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Category {id:" + id + ", name:" + name + "}";
    }
}

exemplo no form jsp:

<div>
    <form:select path="categories" items="${listCategory}" itemLabel="name" itemValue="id"/>
    <form:errors path="categories" />
</div>

HTML gerado:

<div>
    <select id="categories" name="categories" multiple="multiple">
        <option value="1">cat1</option>
        <option value="2">cat2</option>
        <option value="3">cat3</option>
    </select>        
</div>

E controller:

@RequestMapping("/transactions")
public class TransactionController {

    @RequestMapping("/form")
    public ModelAndView form(Transaction transaction) {

        List<Category> listCategory = Arrays.asList(
                new Category(1L, "cat1"), 
                new Category(2L, "cat2"), 
                new Category(3L, "cat3"));

        ModelAndView modelAndView = new ModelAndView("transactions/form");
        modelAndView.addObject("listCategory", listCategory);
        return modelAndView;
    }

    @RequestMapping(method=RequestMethod.POST)
    public String save(@Valid Transaction transaction, BindingResult result, RedirectAttributes redirectAttributes) {

        System.out.println(transaction);
        return "redirect:/transactions");
    }

    ...
}

Dessa forma quando enviamos os dados veja a saída: Transaction{id:null, name:Teste, categories: []}. Fica vazio o conjunto de categorias, mesmo quando enviamos. Isso acontece por uma coisa até simples. Se repararmos no html gerado para o form veremos que o name do select é categories se referindo ao Set de Category, mas os valores enviados são valores numéricos enviados como sempre em String - no caso de múltipla seleção, String[]. Acontece que, como o Spring vai saber onde vai cair esse número ? Uma categoria possui inúmeras propriedades, ele não vai saber que tem que chamar o setId dela.

Poderíamos pensar então em path="categories.id", mas aí temos erro, o Spring reclama. Afinal, um set não tem um getId ou setId internamente.

Poderíamos ir mais longe e pensar em path="categories[0].id. Aqui sim, "funciona", mas não pra um Set, que é uma estrutura não indexada, e sim pra uma List ou um array. Mas teríamos outro problema, o dado sempre recairia sobre a posição zero, anulando a multiseleção.

Aqui não temos jeito. Ou implementamos um converter na mão, ou trabalhamos com um conjunto de algo mais simples, como um String, ou mesmo Long, pra guardar os ids.

private Set<Long> categories = new HashSet<>();

...
public Set<Long> getCategories() {
    return categories;
}

public void setCategories(Set<Long> categories) {
    this.categories = categories;
}

Aqui já teríamos a seguinte saída: Transaction{id:null, name:Teste, categories: [2, 3]}. Melhorou!

Mas com certeza você não quer trabalhar com Set<Long> no seu objeto de domínio, afinal voce precisa de Category na Transaction. Daí a ideia de ter um objeto especialista que representa exatamente o que vem através do formulário. O formulário HTML nos manda array de String ou possivelmente Long ? Então recebemos array/list/set de Long nesse objeto:

Vejamos:

@Controller
@RequestMapping("/transactions")
public class TransactionController {

    @RequestMapping("/form")
    public ModelAndView form(TransactionForm transactionForm) {

        List<Category> listCategory = Arrays.asList(
                new Category(1L, "cat1"), 
                new Category(2L, "cat2"), 
                new Category(3L, "cat3"));

        ModelAndView modelAndView = new ModelAndView("transactions/form");
        modelAndView.addObject("listCategory", listCategory);
        return modelAndView;
    }

    @RequestMapping(method=RequestMethod.POST)
    public String save(@Valid TransactionForm transactionForm, BindingResult result, RedirectAttributes redirectAttributes) {

        Transaction transaction = transactionForm.build();
        System.out.println(transaction);

        return "redirect:/transactions";
    }
    ...
}
<div>
    <form:select path="categoryIds" items="${listCategory}" itemLabel="name" itemValue="id" style="width: 100px;"/>
    <form:errors path="categoryIds" />
</div>

E a classe que representa o form ficaria:

public class TransactionForm {

    private String name;
    private Set<Long> categoryIds = new HashSet<>();

    // getName() e setName()

    public Set<Long> getCategoryIds() {
        return categoryIds;
    }

    public void setCategoryIds(Set<Long> categoryIds) {
        this.categoryIds = categoryIds;
    }

    public Transaction build() {
        Set<Category> categories = categoryIds.stream()
                .map(id -> new Category(id, ""))
                .collect(Collectors.toSet());

        Transaction transaction = new Transaction();
        transaction.setName(this.name);
        transaction.setCategories(categories);

        return transaction;
    }
}

E a saída agora: Transaction{id:null, name:Novo Teste, categories: [Category {id:1, name:}, Category {id:2, name:}]} =)

Essa abordagem é legal pois te ajuda a desacoplar seu formulário do seu objeto de domínio. Quando nossa entidade é complexa, é muito comum ter um form que não corresponda exatamente às suas definições. Sua entidade por exemplo tem mais informações que o form. A transaction que você recebia no controller tinha estado inconsistente em algumas informações. O que aconteceria se eu invocasse getTraBalances() ou getCompany() e tentasse pegar o nome da company ao usar a referência no controller ? =/

Como agora temos um objeto totalmente fiel ao que o form enviou, podemos utilizar sua referência com tranquilidade e nele planejar uma transição - build() - para nossos objetos de domínio, podendo ainda ter uma lógica mais complexa pra isso.

Espero ter ajudado no pensamento. Abraços!

Obrigado !!