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).
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.
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>
(emailService.save()
is just a proxy call to emailDao.save()
)
public void save(Email email)
{
this.getSession().saveOrUpdate(email);
}
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...
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.