Spring MVC complex object data binding - spring

I am still struggling with Spring MVC with what should be a fairly straightforward problem but what seems to be sparsly documented in Spring MVC documentation.
My project uses Spring MVC and Thymeleaf for the views, but the view rendering engine is not really relevant to the problem.
My application is centered around an Activity class which models an (indoor or outdoor) activity which is organized by a member and where fellow members can subscribe to. An Activity has, among others, a Category field and a Region field, which are dropdown fields which are modeled by Hibernate as many-to-one entities to DB lookup tables which contain an id and description field.
The code for the Activity entity class is as follows, the non relevant fields are omitted to shorten the code:
package nl.drsklaus.activiteitensite.model;
//imports
#Entity
#Table(name="activity")
public class Activity {
#Id
#GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
#ManyToOne(cascade=CascadeType.ALL)
#JoinColumn(name="organizer_id")
private Member organizer;
#Size(min=5, max=50)
#Column(name = "title", nullable = false)
private String title;
#Size(min=5, max=500)
#Column(name = "description", nullable = false)
private String description;
#ManyToOne
#JoinColumn(name="category_id")
private ActivityCategory category;
#ManyToOne
#JoinColumn(name="region_id")
private ActivityRegion region;
#ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
#JoinTable(name="member_activity_subscription",
joinColumns = {#JoinColumn(name="activity_id")},
inverseJoinColumns={#JoinColumn(name="member_id")})
private List<Member> participants = new ArrayList<Member>();
//getters and setters
#Override
public int hashCode() {
...
}
#Override
public boolean equals(Object obj) {
...
}
}
In the view, the user should be able to select a Region and Category from a select box. THe options are put in the Model using a #ModelAttribute annotated method on the class level.
THe problem is with the binding of the box to the lookup property fields.
For example the Category field is of the ActivityCategory type, which is an entity class containing an id and a description property.
In the view, the select box is filled with the list of possible options (allCategories which contains ActivityCategory instances), Thymeleaf takes care of selecting the current value by matching the "value" attribute value with the list:
<label>Categorie</label>
<select th:field="*{category}">
<option th:each="cat : ${allCategories}"
th:value="${cat}"
th:text="${cat.description}">
</option>
</select>
The generated HTML looks like:
<select id="category" name="category">
<option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory#20">Actief en sportief</option>
<option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory#21">Uitgaan en nachtleven</option>
<option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory#22" selected="selected">Kunst en cultuur</option>
<option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory#23">Eten en drinken</option>
<option value="nl.drsklaus.activiteitensite.model.lookup.ActivityCategory#24" selected="selected">Ontspanning en gezelligheid</option>
</select>
As we see, the value attributes contain a string representation of the object itself which is clearly not desired, to show the id values we could use ${cat.id} instead of ${cat} but then the selection of the current value (setting the 'selected="selected"' attribute) does not work anymore. THerefore I implemented a Converter which converts an ActivityCategory object to an int (the id value). In Thymeleaf, the converter is called by using the double accolades {{}}:
th:value="${{cat}}"
THe converter is created and added to Spring:
public class LookupConverter implements Converter<LookupEntity, String> {
public String convert(LookupEntity source) {
return String.valueOf(source.getId());
}
}
//In MvcConfig class
#Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new LookupConverter());
}
Now the HTML shows the id values for the options, which is much more logical:
<select id="category" name="category">
<option value="1">Actief en sportief</option>
<option value="2">Uitgaan en nachtleven</option>
<option value="3" selected="selected">Kunst en cultuur</option>
<option value="4">Eten en drinken</option>
<option value="5">Ontspanning en gezelligheid</option>
</select>
But it still wrong after submitting, the id value cannot be bound to the Activity object which expects a ActivityCategory instead if an integer value, so a typeMismatch validation error is generated.
My handler method looks like:
#RequestMapping(value = "/{id}/submit", method = RequestMethod.POST)
public String submitForm(#ModelAttribute("activity") Activity activity, BindingResult result, ModelMap model) {
if (result.hasErrors()) {
return "activityform";
} else {
if (activity.getId() == null) {
this.service.saveActivity(activity);
} else {
this.service.mergeWithExistingAndUpdate(activity);
}
return "redirect:/activity/" + activity.getId() + "/detail";
}
}
I have looked at many posts but still found have no solution for this IMHO pretty trivial issue. How can the String value containing the id be accepted by the handler method and properly converted? Or can we not use the id value for this purpose?
Looking for some hints...

I think you can't use your entity model to submit data from your form to the MVC controller. Try to create a separate form object that matches the form data and write a service method to translate it to entities that can be persisted in the database.

With help from another forum I have found the most elegant solution! Instead of a Converter, we use a Formatter which can convert from specfiec Object type to a String and vice versa. The formatter is registered to Spring and automatically called from Thymeleaf and converts the id field to an ActivityCategory instance with only the id value set. So we do not lookup the actual instance from the database because we do not need the description here, for Hober ate the id is enough to create the query.
My formatter looks like:
public class ActivityCategoryFormatter implements Formatter<ActivityCategory> {
#Override
public String print(ActivityCategory ac, Locale locale) {
// TODO Auto-generated method stub
return Integer.toString(ac.getId());
}
#Override
public ActivityCategory parse(final String text, Locale locale) throws ParseException {
// TODO Auto-generated method stub
int id = Integer.parseInt(text);
ActivityCategory ac = new ActivityCategory(id);
return ac;
}
}
and is registered to Spring (together with the ActivityRegionFormatter for the other lookup field) by:
#Override
public void addFormatters(FormatterRegistry registry) {
//registry.addConverter(new LookupConverter());
registry.addFormatter(new ActivityCategoryFormatter());
registry.addFormatter(new ActivityRegionFormatter());
}
And now it works as expected!
The only remaining issue is that we have some code duplication because the two Formatter classes are almost the same, they only differ in the generic class that is passed in.
I tried to solve this by using a common interface LookupEntity which is implemented by the two lookup entity classes (ActivityCategory and RegionCategory) and use this common interface to define the formatter but unfortunately that did not work...

Related

Thymeleaf th:each cannot find object

I'm using Thymeleaf with springboot 2.1.2, and I have a problem with Thymeleaf's th:each tag.
Here's my code:
<tr th:each="addr : ${user.addressList}">
<td><input type="text" th:value="${addr.name}" th:field="${addr.name}"></td>
<td><input type="text" th:value="${addr.phone}" th:field="${addr.phone}"></td>
<td><input type="text" th:value="${addr.location}" th:field="${addr.location}"></td>
<td><a th:href="#{/user/addr/del/{id}(id=${addr.id})}">del</a></td>
</tr>
However, I encounter the following exception:
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'addr' available as request attribute
The debugger says it can't find the variable, however, I'm sure that the view has a binding object named 'user' because I use it else where in the front.
Here's my controller and my entities:
#GetMapping("/profile")
public String getProfile(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
String email = (String) session.getAttribute("userId");
User user = userService.getProfile(email);
model.addAttribute("user", user);
return "user/profile";
}
#Entity
public class User {
#Id
private String email;
private String userName, password;
private int pts; //积分
private int status= Flags.USER_EXIST;
#OneToMany(targetEntity = Address.class
, fetch = FetchType.LAZY, mappedBy = "user"
, cascade = {CascadeType.PERSIST,CascadeType.REMOVE})
private List<Address> addressList = new ArrayList<>();
//constructors and getters and setters below...
}
#Entity
public class Address {
#Id
#GeneratedValue //去掉这个注解会导致一系列bug
private long id;
private String name, phone, location;
#ManyToOne(targetEntity = User.class)
private User user;
//constructors and getters and setters below...
}
I follow the tutorial here and I can't find any difference between my th:each usage and the tutorial's. Could you help me?
UPDATE ============================================>
Here's my code for UserService.getProfile(String email):
#Override
public User getProfile(String email) {
Optional<User> res = userRepository.findById(email);
return res.isPresent() ? res.get() : null;
}
UPDATE again ==========================================================>
summerize what i've done so far:
1) use FetchType.EAGER in User class
2) force the repository to retrieve the relevant Address by printing the addressList:
user.getAddressList().stream().forEach(x -> System.out.println(x.getLocation()));
3) delete the th:each block and restart my app, there is no exception; when i add back the block, my app fails again...
All of these measures don't seem to help me....
Problem not related to JPA after all. The problem is how you use th:field. See documentation about correct usage: https://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#dynamic-fields
Try this:
<tr th:each="addr,addrStat : ${user.addressList}">
<td><input type="text" th:value="${addr.name}" th:field="*{addressList[__${addrStat.index}__].name}"></td>
...
You're having this error :-
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'addr' available as request attribute
Because you're using #ModelAttribute annotation on your controller. You're trying to send an object named addr to your controller after submit , However you're not sending any object by the exact name addr in/from your controller from where you're opening your requested html page.
Here's a quick example of how you send object using #ModelAttribute annotation :--
1.) Your requested controller from where you're opening the requested HTML page would be something like this: -
#RequestMapping("/test")
public String test(HttpServletRequest request,Model model){
//your code
model.addAttribute("addr",new MyTest());
return "yourRequestedPage";
}
2.) And after submit your next controller would be something like this :-
#RequestMapping("/testAfterSubmit")
public String someMethodName(#ModelAttribute("addr") MyTest myTestClass,HttpServletRequest request,Model model ) {
//your code . .
}
PS - For more information what #ModelAttribute does/is , here's a quick reference :-
What is #ModelAttribute in Spring MVC?
I thought I've found the problem...It's very tricky...I edit the th:each block, and now it's like :
<tr th:each="addr : ${user.addressList}">
<td><input type="text" th:value="${addr.name}"></td>
<td><input type="text" th:value="${addr.phone}"></td>
<td><input type="text" th:value="${addr.location}"></td>
<td><a th:href="#{/user/addr/del/{id}(id=${addr.id})}">del</a></td>
</tr>
Have you found the difference? Yes i remove the th:field tag! In the origin code, what i did is binding the Address object's attribute in the list to a iterator! Maybe it's readonly i think...so the compiler would expect an object named 'addr' in the model, of course this would fail...
This bug alarms me to separate the modification function and the presentation function into respective view...

Have one Rest repository json with everything and one with fields excluded

I have two entities: Book and Category and a repository for both. In the controller, I have set up the methods correctly as such:
#RequestMapping(value="/books", method = RequestMethod.GET)
#CrossOrigin
public #ResponseBody List<Book> bookListRest() {
return (List<Book>) bookRepository.findAll();
}
This obviously shows all books and every field in the entity that isn't #JsonIgnore'd. The problem is, I need to have:
One page with Book data (book name, author name, isbn..) without category
One page with Category data (Category name) without books
One page with Everything (book data along with categories where they belong in)
How can one accomplish this?
I somehow need to in a way ignore #jsonignore on some occasions. Should I make a new entity that extends say, Question and also make a repository for that? Surely that can't be the correct way to do this.
As khalid Ahmed Said you can use costum dtos or you can add Filters to ignore specific fields in Jackson. First, we need to define the filter on the java object:
#JsonFilter("myFilterBook")
public class Book{
...
}
#JsonFilter("myFilterCategory")
public class Category{
...
}
Before you return your ResponseBody you try to use ObjectMapper (Jackson):
The case of one page with Book data (book name, author name, isbn..) without category:
ObjectMapper mapper = new ObjectMapper();
SimpleBeanPropertyFilter theFilter = SimpleBeanPropertyFilter
.serializeAllExcept("category");
FilterProvider filters = new SimpleFilterProvider()
.addFilter("myFilterBook", theFilter);
String dtoAsString = mapper.writer(filters).writeValueAsString(book);
You can do the same think by putting what you want o ignore for the other example.
And for more details to ignore field during marshalling with jackson you can check here
What about using DTOs data transfer objects
you can create multiple DTOs to use them in the response of your API
DTO is a pojo class that customize the returning data from your entity
public class BookWithoutCategoryDTO {
private String name;
private String authorName;
.....
/// and make setters and getters for them
}
public class BookWithCategoryDTO {
private String name;
private String authorName;
private String category;
.....
/// and make setters and getters for them
}
and create your custom mapper to convert from Book to BookDTO

Adding a Spring Data Repository disables functionality

I have these two entities.
#Entity
public class Person {
#Id #GeneratedValue
private Long id;
private String name;
#ManyToOne(cascade=CascadeType.ALL)
private Location location;
public Person() {
}
#Entity
public class Location {
#Id #GeneratedValue
private Long id;
private String place;
#OneToMany(cascade = CascadeType.ALL, mappedBy = "location")
private Set<Person> persons;
public Location() {
}
I also have this Controller.
#Controller
public class PersonController {
private final PersonRepository repo;
public PersonController(PersonRepository repo) {
this.repo = repo;
}
#GetMapping("/")
public String newPerson(Person person){
return "home";
}
#PostMapping("/")
public String newPerson(Person person, BindingResult result){
repo.save(person);
return "redirect:/person";
}
And this Repository.
#Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
Optional<Person> findFirstByName(String name);
}
I also have this backing form.
<form action="#" th:action="#{/}" th:object="${person}" method="post">
<table>
<tr>
<td>Name:</td>
<td><input type="text" th:field="*{name}" /></td>
</tr>
<tr>
<td>Location:</td>
<td><input type="text" th:field="*{location}" /></td>
</tr>
<tr>
<td><button type="submit">Submit</button></td>
</tr>
</table>
</form>
This all works fine when I submit some data. A Person object is saved and so is a Location object.
But when I add
#Repository
public interface LocationRepository extends JpaRepository<Location,
Long> {)
the Location object does not save to the database when I submit the same exact form. Why would just adding this repository cause this issue and what is the solution? Thanks.
You whould fix your form in order to write attribute of location property:
<td><input type="text" th:field="*{location.place}" /></td>
Also you don't have to put #Repository annotation on your repositories.
To elaborate on why things work as they work:
The form binding uses the ConversionService. Spring Data registers a conversion chain from String -> id type -> entity type for each repository managed domain class. So the moment you add a repository, the String submitted as value for Person.location will be interpreted as an identifier for an already existing location. It will cause a by-id lookup with the value submitted for the field named location.
This is handy in the following scenario: assume you're Location is basically a curated list of instances held in the database, e.g. a list of countries. They you don't want to arbitrarily create new ones but rather select one from the overall list, which basically boils down to having to use a dropdown box instead of a text field.
So conceptually, the fundamental things at odds are the cascades (as they indicate a composition, i.e. Location being part of the aggregate) and the existence of LocationRepository as a repository causes the managed type to implicitly becoming an aggregate root (which is fundamental DDD).
This in turn means you have to handle the lifecycle of that instance separately. A potential solution is to inspect the Location bound to the Person, check whether an instance with that place already exists (via a query method on LocationRepository) and if so, replace the one bound with the one loaded or just call LocationRepository.save(…) with the original instance to create a new one.
I still don't totally buy that the original attempt created a correct Location as from your template Spring Framework is not able to guess that what you submit as location is supposed to be the place actually. So I assume you saw a Location instance being created, but completely empty and the BindingResult actually carrying an error, claiming it couldn't transform the location form field into an instance of Location.

Custom bean validation not working

In my Student class I have many fields which I am storing in the database and I also have one field to store photo( for that I am using MultiPartFile datatype) and I am validating this field using custom validation.
Below is code for validation
#Component
public class PhotoValidator implements Validator{
#Override
public boolean supports(Class<?> clazz) {
return Student.class.isAssignableFrom(clazz);
}
#Override
public void validate(Object target, Errors errors) {
Student student=(Student)target;
if(student.getStudentPhoto()!=null){
if(student.getStudentPhoto().getSize()==0){
errors.rejectValue("file", "missing.file");
}
}
if(!student.getStudentPhoto().getOriginalFilename().endsWith(".jpg")){
errors.rejectValue("file", "invalid.file");
}
}
}
In the controller I am implementing it like this
#InitBinder
protected void initBinderStudent(WebDataBinder binder) { binder.setValidator(photoValidator);
}
My Student Model is :-
#Entity
#Table(name = "STUDENT")
public class Student extends UrlEntity {
#Transient
MultipartFile studentPhoto;
#Column(name = "COURSE_TYPE", nullable = false)
#NotNull(message = "Course Type: Course Type can not be left blank")
private in.jmi.constants.CourseType courseType;
#OneToOne(cascade = CascadeType.ALL, optional = false)
#JoinColumn(name = "STUDENT_USER")
#Valid
private User user;
This custom validation of photo is not working and it also mess up the other annotation based validation that I am having here.
I have checked many posts in stackoverflow but couldn't find any relation to this particular problem.
Note:-If I remove the validation code from controller the code works just fine doing all the validations it is supposed to do.
You are mixing approaches in your example. You are not showing the imports in your code example, but the PhotoValidator class does not implement a Bean Validation constraints. It might be some Spring/JSF specific validator!?
To implement a Bean Validation constraint, you need to define a constraint annotation and at least one implementing ConstraintValidator. This is all described in Creating custom constraints. There are plenty of examples out there how to write a custom constraint.

Posting a complete model object to the controller when only few attributes are used in a form

I've read somewhere that for spring mvc, it is a expected behavior to get back NULL in case a form does not contain all the attributes of the model object set by the #ModelAttribute annotiation. S how can I use forms that don't have all the fields of the model object and still recieve the whole but updated object back to the post method of the controller.
A short example code of my intention:
Part of the controller:
....
#RequestMapping(value = "/edit/{id}", method = RequestMethod.GET)
public String editPost(Model model, #PathVariable Integer id) {
model.addAttribute("editPost", bPostService.getPost(id));
return "editPost";
}
#RequestMapping(value = "/edit/{id}", method = RequestMethod.POST)
public String editProcessPost(Model model, #PathVariable Integer id, #ModelAttribute BPost editPost) {
bPostService.updatePost(editPost);
model.addAttribute("posts", bPostService.getPosts());
return "redirect:/";
}
....
The entity mapped by hibernate:
#Entity
#Table(name = "sometable")
public class BPost {
#Id
#GeneratedValue
#Column(name = "id")
private int id;
#Column(name = "title")
private String title;
#Column(name = "description")
private String description;
#Column(name = "text")
private String text;
#Column(name = "anothertext")
private String anothertext;
// getters and setters
}
Part of the JSP view:
<form:form method="POST" modelAttribute="editPost" action="${pageContext.request.contextPath}/secure/post/edit/${editPost.id}">
<table>
<tbody>
<tr>
<td>title:</td>
<td><form:input path="title"></form:input></td>
</tr>
<tr>
<td>description:</td>
<td><form:input path="description"></form:input></td>
</tr>
<tr>
<td>text:</td>
<td><form:input path="text"></form:input></td>
</tr>
<tr>
<td><input value="Edit" type="submit"></td>
<td></td>
</tr>
</tbody>
</table>
</form:form>
As you can see the "anothertext" attribute is not used on the JSP, but I wan't it unchanged returned to the POST method of the controller. Is that possible?
I know some one probably already asked this question, but I cant find the answer to that.
Thank!
You might not want to use the entity as a form backing object which could have security implications. For example an malicious request could be forged to set some unwanted properties.
Therefor it's better in general to create a explicit form backing object for each form to process. It will require you to write more code but it also negates some common problems (like the one you're having).
When using a form backing object your handler looks more like:
Note that I altered the BPost argument to BPostForm.
#RequestMapping(value = "/edit/{id}", method = RequestMethod.POST)
public String editProcessPost(Model model, #PathVariable Integer id, #ModelAttribute BPostForm editPost) {
// fetch the original post
BPost post = bPostService.findById(editPost.getId());
// set the properties
post.setTitle(editPost.getTitle());
post.setDescription(editPost.getDescription());
post.setText(editPost.getText());
// update
bPostService.updatePost(post);
model.addAttribute("posts", bPostService.getPosts());
return "redirect:/";
}
P.s. Using bPostService.getPosts() to add posts to the model and immediately return a redirect seems rather pointless ;)
[EDIT] Validation
Your form backing object can be validated using declarative validation using the Hibernate annotations or settings a custom validator in the WebdataBinder.
Hibernate annotations
When using the Hibernate annotations you can place any annotation on a field or getter. For these validations to kick in you'll need to do two things.
Register a validator bean org.springframework.validation.beanvalidation.LocalValidatorFactoryBean.
Annotate the form backing object's argument in your handler with #valid.
Example: public String editProcessPost(Model model, #PathVariable Integer id, #ModelAttribute #Valid BPostForm editPost, BindingResult result)
Note that using validation needs a BindingResult to be present in the argument list and it needs to be directly after the backing object. This BindingResult will be a container for all validation errors.
Custom validator
A custom validator is a bit more work. You will need to write your own first.
MyPostValidator extends org.springframework.validation.Validator
After writing the validator you can add it to the WebDataBinder.
#InitBinder
public void initBinder(WebDataBinder binder) {
binder.setValidator(new MyPostValidator());
}
It's much easier than this actually....I've been using this method for years
In your Controller class do the following:
// this method gets called by Spring after the GET/POST request but before the // binding of request parameters...
// for POST requests, we want the enity
#ModelAttribute("formName") // <---- Note the attribute name...this is important
public Object getFormBackingObject(HttpServletRequest request) {
if(!request.getMethod().equals("POST")) {
return null;
}
// find primary key
String id = request.getParameter("id");
return serviceObject.getMyEntity(id);
}
#RequestMapping(value="/edit/{id}", method=RequestMethod.POST)
public String editProcessPost(#PathVariable Integer id, #ModelAttribute("formName") BPostForm editPost) {
// editPost is the fully populated entity from the DB after request params
// have been bound to it.
myService.save(editPost);
return "whatever....";
}
As suggested by #Bart if possible replace the usage of entity pojo to form pojo directly in the jsp. If you want to continue with the existing approach you can use those fields as hidden parameters.
<form:hidden path="anothertext"/>
So when the form is getting submitted this value will be set automatically.
Here you may have another 2 issues.
Hidden Field with another value
Suppose you want to keep it as hidden value, but value should be different, then you can use like below.
<input type="hidden" name="anothertext" value ="{object.value}">
where object is any object available in the view scope.
Hidden Field As Object
What if you have have anothertext as object instead of plain text. For example if it is a User object with id and name as value and you have used like below under your entity
#OneToOne
#JoinColumn(name = "ownerName", referencedColumnName = "name")
private User owner;
In that case you have to use two values as hidden parameters. The id field of the object and the linking field (here it is name)
So it will go like below.
<form:hidden path="owner.id"/>
<form:hidden path="owner.name"/>
While persisting this back, hibernate will automatically merge with existing user from db with same id.

Resources