A case study on collection data binding with Spring and Backbone
While developing a web application with Spring boot as the main framework, I came across the need to add, remove and modify a list of elements in a web page and submit these changes to the server. Although it's a common task for a web framework, I found it to be non-trivial. So, I set out to write a case study to help me organize what I've learnt and maybe help others.
The application resulting from this case study is running live thanks to Heroku.
Consider a web page showing a table of users like this one:
Name | |
---|---|
John | john@mail.com |
Mike | mike@mail.com |
Lisa | lisa@mail.com |
This is the set of actions to perform on this list of users:
The main components involved in this case study are:
@Controller
with methods to handle GET
and POST
requests.User
class to hold the values for name
and email
.Form
class. This is just a POJO
to hold a collection of User
s.HTML
template using Thymeleaf to render @Controller
results.JavaScript
code using Backbone to dynamically add or remove table rows.Note that the first three items in the list above address the server-side aspect of the problem, while the last two refer to the client-side.
The following sections will focus on the server-side where the databinding occurs, taking the POST
request parameters as the starting point, no matter how the client-side managed to produce these. At the end of this article, a section will be devoted to describe the client-side.
This is the list of cases to study:
List
, where the collection of User
s is actually an empty java.util.List
. It's worth studying this case for its simplicity and also because there may be times where the collection will only get created once and not edited thereafter.Map
, where the collection of User
s is actually a non-empty java.util.Map
. The case will start showing the problems with a non-empty List
and indexed-based databinding, and then overcome these problems using Map
instead of List
.JPA
, and how to perform databinding when the collection of User
s is retrieved directly from a @Repository
using spring-data-jpa module.Let's start with an empty List
of User
s. Adding a new User
to this List
means to send a POST
request to the @Controller
with the name
and email
values for the new user. In order for Spring
to bind this data, the request parameters should follow the convention described in section Beans of the Spring Framework documentation. For this case, the POST
request parameters look like:
users[0].name=John
users[0].email=john@mail.com
Upon request, Spring will try to bind this data to the @ModelAttribute
object of type Form
defined inside the @Controller
class.
Spring will create an instance of User
and set the value "John" for property name
and "john@mail.com" for property email
. It will then insert this new User
instance at index 0 in the users
property of the form
instance of type Form
.
Here's the relevant code:
<form method="post">
<input type="hidden" name="users[0].name" value="Mike">
<input type="hidden" name="users[0].email" value="mike@mail.com">
</form>
public class User {
// Getters and Setter ommited for the sake of brevity.
private String name;
private String email;
}
public class Form {
private List<User> users;
public List<User> getUsers() {
return users;
}
public void setUsers(List<User> users) {
this.users = users;
}
}
@Controller
public class DataBindingController {
private List<User> users;
@RequestMapping(value = "/", method = RequestMethod.POST)
public String updateUsers(@ModelAttribute("form") Form form) {
this.users = form.getUsers();
return "redirect:/";
}
@ModelAttribute("form")
public Form getForm() {
Form form = new Form();
form.setUsers(this.users);
return form;
}
}
And that's it. The method updateUsers
gets a fully populated Form
instance by parameter, with all the databinding job done.
However, this is the most simple scenario, since the List
is empty. But:
List
already contains items?GET
and POST
requests?List
between those two requests?Let's address these questions.
Map
Binding a collection of objects would be as simple as described just before if only the List
passed to the view on the GET
request would be empty and stayed empty until the databinding process finished processing the POST
request. This is so because the binding of the objects is done according to the index
each object is stored in the List
. If indexes change between GET
and POST
, the reference is lost, and the databinder will confuse the objects.
Let's set an example to illustrate the problem with changing indexes
. This is a List
returned by the GET
request and rendered as a table in the web page
Index | Name | |
---|---|---|
0 | John | john@mail.com |
Now a new row is added at with data about Lisa at client-side.
Index | Name | |
---|---|---|
0 | John | john@mail.com |
1(new) | Lisa | lisa@mail.com |
Just before sending the data above as a POST
request to the server, the List
in the server-side is changed, so that Mike also gets added at index
1.
Index | Name | |
---|---|---|
0 | John | john@mail.com |
1(new) | Mike | mike@mail.com |
When the POST
request sends the data about Lisa, it will overwrite Mike as a result of databinding based on indexes
, and Mike's data will get lost:
Index | Name | |
---|---|---|
0 | John | john@mail.com |
1 | Lisa | lisa@mail.com |
Let's try to overcome the problems with indexes
by introducing an identifier for objects of class User
.
public class User {
private Long id;
private String name;
private String email;
// Setters and Getters omitted for the sake of brevity
}
In order to leverage identifiers, let's change the type of the users from List
to Map
. The Map
will be populated with exising users storing each at key=id.
public class Form {
private Map<Long, User> users;
public Map<Long, User> getUsers() {
return users;
}
public void setUsers(Map<Long, User> users) {
this.users = users;
}
}
Going back to the example, the view will again render this table upon GET
request, but this time the id
for John will be 1, which will match with the key
in the Map.
Key | Id | Name | |
---|---|---|---|
1 | 1 | John | john@mail.com |
Again, a new row is added with data about Lisa at client-side. Since we need to store it with some key in the Map
that won't collide with the existing ids, let's choose negative integers as keys for new users, taking for granted that identifiers will always be positive integers, generated and assigned at server-side.
Key | Id | Name | |
---|---|---|---|
1 | 1 | John | john@mail.com |
-1 | 1 | Lisa | lisa@mail.com |
Once more, just before sending the data above as a POST
request to the server, the Map
at the server-side is changed, so that Mike also gets added with id=2, since John already has id=1.
Key | Id | Name | |
---|---|---|---|
1 | 1 | John | john@mail.com |
2 | 2 | Mike | mike@mail.com |
When the POST
request sends the data about Lisa, Spring will create a new User
object for Lisa and put it at key=-1 inside the Map
.
Key | Id | Name | |
---|---|---|---|
1 | 1 | John | john@mail.com |
2 | 2 | Mike | mike@mail.com |
-1 | null | Lisa | lisa@mail.com |
Once databinding is done, the server-side will assign Lisa an id=3.
Modifying users is given for free with this setup. The client-side only needs to send the data of the row that's changed. Spring will update the data in the object that's stored in the Map
at the specific key that matches the object's id.
For instance, if Mike's email gets changed:
Key | Id | Name | |
---|---|---|---|
2 | 2 | Mike | mike_changed@mail.com |
Then the form should submit:
<form method="post">
<input type="hidden" name="users[2].email" value="mike_changed@mail.com">
</form>
And that's it!
In order to tell which users are removed, the client-side will set id=null, but keeping the key value. For instance, if John gets removed:
Key | Id | Name | |
---|---|---|---|
1 | null | John | john@foo.bar |
2 | 2 | Mike | mike@mail.com |
3 | 3 | Lisa | lisa@mail.com |
The server-side will then remove all instances with null id.
To sum it up, this is the databinding contract for Map
-backed collection of items:
Map
with negative key values and id=nullMap
with the same key, but setting id=nullMap
with the same key and same id, setting the new values for the modified properties.JPA
on a @OneToMany
relationshipUp to this point, the collection of Users
have been stored in-memory using a List
or a Map
. However, a real-life application would typically use something like a database to store data. So, let's see how the databinding is done using the spring-data-jpa module.
First thing to change will be the repository
in the @Controller
from List
or Map
to Repository
.
@Controller
public class JPADataBindingController {
@Autowired
private IUserRepository repository;
...
}
The interface IUserRepository
provides access to persisted User
s and methods to save and update new User
s.
public interface IUserRepository extends CrudRepository<User, Long> {
}
As you can see it's just an empty interface that specifies two Java Generics type parameters: the type of object to persist which shall be User
, and the type of identifier which shall be Long
. This interface will inherit methods such as findOne(Long id)
, findAll()
, and save(User e)
from the CrudRepository
interface. There's no need to implement anything: Spring will :sparkles: automagically :sparkles: do eveything for us!
Map
Before moving on to @OneToMany
relationships, let's see how the case for Map
seen before seen before applies to JPA
.
JPA
methods for retrieving collections of @Entities
like findAll()
return an Iterable
. Since we need a Map
in our Form
object, we need to do some transformation in @ModelAttribute
annotated method, like this:
@ModelAttribute("form")
public Form getForm() {
Form form = new Form();
Map<Long, User> usersMap = new HashMap<Long, User>();
Iterable<User> usersInRepository = this.repository.findAll();
for (User user : usersInRepository) {
usersMap.put(user.getId(), user);
}
form.setUsers(usersMap);
return form;
}
Hence, the view will work just as described in the case for Map
. Upon POST
request, the Map
needs to be processed to call @Repository
save()
or delete()
methods according to the Map
databinding contract.
@RequestMapping(value = "/jpa", method = RequestMethod.POST)
public String updateUsers(@ModelAttribute("form") Form form) {
// Save the binded data to our "Repository"
Set<Entry<Long, User>> entrySet = form.getUsers().entrySet();
for (Entry<Long, User> entry : entrySet) {
Long key = entry.getKey();
User user = entry.getValue();
// Decide if this item gets deleted or needs to be saved
if (key > 0 && user.getId() == null) {
this.repository.delete(user);
} else {
this.repository.save(user);
}
}
return "redirect:/jpa";
}
:children_crossing: work in progress
Now that there's a databinding contract in place, let's see how to play by these rules at client-side.
We'll be using:
User
s retrieved from the Repository
.