Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Spring and/or Hibernate: Saving many-to-many relations from one side after form submission

The context

I have a simple association between two entities - Category and Email (NtoM). I'm trying to create web interface for browsing and managing them. I have a simple e-mail subscription edit form with list of checkboxes that represents categories, to which given e-mail belongs (I registered property editor for Set<Category> type).

The problem

Form displaying works well, including marking currently assigned categories (for existing e-mails). But no changes are saved to EmailsCategories table (NtoM mapping table, the one defined with @JoinTable - neither newly checked categories are added, nor unchecked categories are removed.

The code

Email entity:

@Entity
@Table(name = "Emails")
public class Email
{
    @Id
    @GeneratedValue(generator = "system-uuid")
    @GenericGenerator(name = "system-uuid", strategy = "uuid2")
    @Column(length = User.UUID_LENGTH)
    protected UUID id;

    @NaturalId
    @Column(nullable = false)
    @NotEmpty
    @org.hibernate.validator.constraints.Email
    protected String name;

    @Column(nullable = false)
    @Temporal(TemporalType.TIMESTAMP)
    protected Date createdAt;

    @Column
    protected String realName;

    @Column(nullable = false)
    protected boolean isActive = true;

    @ManyToMany(mappedBy = "emails", fetch = FetchType.EAGER)
    protected Set<Category> categories = new HashSet<Category>();

    public UUID getId()
    {
        return this.id;
    }

    public Email setId(UUID value)
    {
        this.id = value;

        return this;
    }

    public String getName()
    {
        return this.name;
    }

    public Email setName(String value)
    {
        this.name = value;

        return this;
    }

    public Date getCreatedAt()
    {
        return this.createdAt;
    }

    public String getRealName()
    {
        return this.realName;
    }

    public Email setRealName(String value)
    {
        this.realName = value;

        return this;
    }

    public boolean isActive()
    {
        return this.isActive;
    }

    public Email setActive(boolean value)
    {
        this.isActive = value;

        return this;
    }

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

    public Email setCategories(Set<Category> value)
    {
        this.categories = value;

        return this;
    }

    @PrePersist
    protected void onCreate()
    {
        this.createdAt = new Date();
    }
}

Category entity:

@Entity
@Table(name = "Categories")
public class Category
{
    @Id
    @GeneratedValue(generator = "system-uuid")
    @GenericGenerator(name = "system-uuid", strategy = "uuid2")
    @Column(length = User.UUID_LENGTH)
    protected UUID id;

    @NaturalId(mutable = true)
    @Column(nullable = false)
    @NotEmpty
    protected String name;

    @ManyToMany
    @JoinTable(
        name = "EmailsCategories",
        joinColumns = {
            @JoinColumn(name = "idCategory", nullable = false, updatable = false)
        },
        inverseJoinColumns = {
            @JoinColumn(name = "idEmail", nullable = false, updatable = false)
        }
    )
    protected Set<Email> emails = new HashSet<Email>();

    public UUID getId()
    {
        return this.id;
    }

    public Category setId(UUID value)
    {
        this.id = value;

        return this;
    }

    public String getName()
    {
        return this.name;
    }

    public Category setName(String value)
    {
        this.name = value;

        return this;
    }

    public Set<Email> getEmails()
    {
        return this.emails;
    }

    public Category setEmails(Set<Email> value)
    {
        this.emails = value;

        return this;
    }

    @Override
    public boolean equals(Object object)
    {
        return object != null
            && object.getClass().equals(this.getClass())
            && ((Category) object).getId().equals(this.id);
    }

    @Override
    public int hashCode()
    {
        return this.id.hashCode();
    }
}

Controller:

@Controller
@RequestMapping("/emails/{categoryId}")
public class EmailsController
{
    @Autowired
    protected CategoryService categoryService;

    @Autowired
    protected EmailService emailService;

    @ModelAttribute
    public Email addEmail(@RequestParam(required = false) UUID id)
    {
        Email email = null;

        if (id != null) {
            email = this.emailService.getEmail(id);
        }
        return email == null ? new Email() : email;
    }

    @InitBinder
    public void initBinder(WebDataBinder binder)
    {
        binder.registerCustomEditor(Set.class, "categories", new CategoriesSetEditor(this.categoryService));
    }

    @RequestMapping(value = "/edit/{id}", method = RequestMethod.GET)
    public String editForm(Model model, @PathVariable UUID id)
    {
        model.addAttribute("email", this.emailService.getEmail(id));

        model.addAttribute("categories", this.categoryService.getCategoriesList());

        return "emails/form";
    }

    @RequestMapping(value = "/save", method = RequestMethod.POST)
    public String save(@PathVariable UUID categoryId, @ModelAttribute @Valid Email email, BindingResult result, Model model)
    {
        if (result.hasErrors()) {
            model.addAttribute("categories", this.categoryService.getCategoriesList());
            return "emails/form";
        }

        this.emailService.save(email);

        return String.format("redirect:/emails/%s/", categoryId.toString());
    }
}

Form view:

<form:form action="${pageContext.request.contextPath}/emails/${category.id}/save" method="post" modelAttribute="email">
    <form:hidden path="id"/>
    <fieldset>
        <label for="emailName"><spring:message code="email.form.label.Name" text="E-mail address"/>:</label>
        <form:input path="name" id="emailName" required="required"/>
        <form:errors path="name" cssClass="error"/>

        <label for="emailRealName"><spring:message code="email.form.label.RealName" text="Recipient display name"/>:</label>
        <form:input path="realName" id="emailRealName"/>
        <form:errors path="realName" cssClass="error"/>

        <label for="emailIsActive"><spring:message code="email.form.label.IsActive" text="Activation status"/>:</label>
        <form:checkbox path="active" id="emailIsActive"/>
        <form:errors path="active" cssClass="error"/>

        <form:checkboxes path="categories" element="div" items="${categories}" itemValue="id" itemLabel="name"/>
        <form:errors path="categories" cssClass="error"/>

        <button type="submit"><spring:message code="_common.form.Submit" text="Save"/></button>
    </fieldset>
</form:form>

Edit - added DAO code

(emailService.save() is just a proxy call to emailDao.save())

public void save(Email email)
{
    this.getSession().saveOrUpdate(email);
}

Edit 2 - little more debug/logs

A simple test snippet:

public void test()
{
    Category category = new Category();
    category.setName("New category");
    this.categoryDao.save(category);

    Email email = new Email();
    email.setName("test@me")
        .setRealName("Test <at> me")
        .getCategories().add(category);
    this.emailDao.save(email);

}

And these are logs:

12:05:34.173 [http-bio-8080-exec-23] DEBUG org.hibernate.SQL - insert into Emails (createdAt, isActive, name, realName, id) values (?, ?, ?, ?, ?)
12:05:34.177 [http-bio-8080-exec-23] DEBUG org.hibernate.persister.collection.AbstractCollectionPersister - Inserting collection: [pl.chilldev.mailer.web.entity.Category.emails#24d190e3-99db-4792-93ea-78c294297d2d]
12:05:34.177 [http-bio-8080-exec-23] DEBUG org.hibernate.persister.collection.AbstractCollectionPersister - Collection was empty

Even with this logs it seems a little strage - it tels that it's inserting collection with one element, but then it tells it was empty...

like image 868
Rafał Wrzeszcz Avatar asked Oct 09 '13 18:10

Rafał Wrzeszcz


People also ask

How do you persist many-to-many relationships in JPA?

In JPA we use the @ManyToMany annotation to model many-to-many relationships. This type of relationship can be unidirectional or bidirectional: In a unidirectional relationship only one entity in the relationship points the other. In a bidirectional relationship both entities point to each other.

What should be done for many-to-many joins in hibernate?

In order to map a many-to-many association, we use the @ManyToMany, @JoinTable and @JoinColumn annotations. Let's have a closer look at them. The @ManyToMany annotation is used in both classes to create the many-to-many relationship between the entities.

What is one to many and many to one relationship in spring boot?

The @ManyToOne annotation is used to define a many-to-one relationship between two entities in Spring Data JPA. The child entity, that has the join column, is called the owner of the relationship defined using the @ManyToOne annotation.


1 Answers

Here we go again.

A bidirectional association has two sides: an owner side, and an insverse side. The owner side is the one without the mappedBy attribute. To know which association exists between entities, JPA/Hibernate only cares about the owner side. Your code only modifies the inverse side, and not the owner side.

It's YOUR job to maintain the coherence of the object graph. It's sometimes acceptable to have an incoherent object graph, but not modifying the owner side won't make the changes persistent.

So you need to add

category.getEmails().add(email);

or to choose Email as the owner side rather than Category.

like image 64
JB Nizet Avatar answered Oct 25 '22 05:10

JB Nizet