Search code examples
javaspringspring-mvcjpathymeleaf

Spring Boot and Thymeleaf: Object id field is null when sending back to controller


I am using Spring MVC with JPA (Hibernate). The problem is that when I try to edit a user the id is not sent back (null) and therefore the repository save method makes a new User object instead of updating it. I have a User class which has a MappedSuperClass BaseEntity. The mapping is implemented as follows:

BaseEntity.java

@MappedSuperclass
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Version
    private Long version;

    protected BaseEntity() {
        id = null;
    }

    public Long getId() {
        return id;
    }

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

    public Long getVersion() {
        return version;
    }

    public void setVersion(Long version) {
        this.version = version;
    }
}

User.java

@Entity
public class User extends BaseEntity implements UserDetails {
    @NotNull
    @Size(min = 5, max = 30)
    private String name;
    private String username;
    private LocalDate dateOfBirth;
    private String address;
    @JsonIgnore
    private String email;
    @JsonIgnore
    private String password;
    private String barcode;
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "role_id")
    @JsonIgnore
    private Role role;
    @JoinTable(name = "loan", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "book_id"))
    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    private Set<Book> loanedBooks;

    private boolean enabled;

    public User() {
        super();
    }

    public User(String name, String username, String email, String password, Role role, boolean enabled, LocalDate dateOfBirth) {
        this();
        this.name = name;
        this.username = username;
        this.email = email;
        setPassword(password);
        this.role = role;
        this.enabled = enabled;
        this.dateOfBirth = dateOfBirth;
        loanedBooks = new HashSet<>();

    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(role.getName()));
        return authorities;
    }

    public void loanBook(Book book) throws BookNotAvailableException{
        if (book.isAvailable()) {
            loanedBooks.add(book);
        } else {
            throw new BookNotAvailableException("Book is not available right now");
        }

    }

    public void returnBook(Book book) {
        for (Book returnBook : loanedBooks) {
            if (returnBook.getBarcode().equals(book.getBarcode()));
            loanedBooks.remove(returnBook);
        }
    }

    public Set<Book> getLoanedBooks() {
        return loanedBooks;
    }

    public void setLoanedBooks(Set<Book> loanedBooks) {
        this.loanedBooks = loanedBooks;
    }


    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public String getPassword() {
        return password;
    }

    public String getName() {
        return name;
    }

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

    public String getBarcode() {
        return barcode;
    }

    public void setBarcode(String barcode) {
        this.barcode = barcode;
    }

    @Override
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Role getRole() {
        return role;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public void setRole(Role role) {
        this.role = role;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    public LocalDate getDateOfBirth() {
        return dateOfBirth;
    }

    public void setDateOfBirth(LocalDate dateOfBirth) {
        this.dateOfBirth = dateOfBirth;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }
}

UserController.java

@Controller
public class UserController {
    @Autowired
    UserService userService;

    @GetMapping(path = "/users")
    @PreAuthorize("hasAnyRole('ROLE_ADMIN','ROLE_LIBRARIAN')")
    public String index(Model model) {
        return "user/index";
    }

    // Form for editing an existing user
    @RequestMapping("users/{userId}/edit")
    public String formEditUser(@PathVariable Long userId, Model model) {
        // TODO: Add model attributes needed for new form
        if (!model.containsAttribute("user")) {
            model.addAttribute("user", userService.findById(userId));
        }
        model.addAttribute("action", String.format("/users/%s", userId));
        model.addAttribute("heading", "Edit User");
        model.addAttribute("submit", "Update");
        return "user/form";
    }

    // Update an existing user
    @RequestMapping(value = "/users/{userId}", method = RequestMethod.POST)
    public String updateUser(@Valid User user, BindingResult result, RedirectAttributes redirectAttributes) {
        // Update user if valid data was received
        if (result.hasErrors()) {
            // Include validation errors upon redirect
            redirectAttributes.addFlashAttribute("org.springframework.validation.BindingResult.user", result);
            // Add user if invalid data was received
            redirectAttributes.addFlashAttribute("user", user);
            // Redirect back to the form
            return String.format("redirect:/users/%s/edit", user.getId());
        }

        userService.save(user);

        redirectAttributes.addFlashAttribute("flash", new FlashMessage("User successfully updated!", FlashMessage.Status.SUCCESS));

        //Redirect browser to /users
        return "redirect:/users";
    }

    // Form for adding a new user
    @RequestMapping(value = "users/add", method = RequestMethod.GET)
    public String formNewUser(Model model) {
        //..
    }


    // Add a User
    @RequestMapping(value = "/users", method = RequestMethod.POST)
    public String addUser(@Valid User user, BindingResult result, RedirectAttributes redirectAttributes) {
        // Add user if valid data was received
        if (result.hasErrors()) {
            // Include validation errors upon redirect
            redirectAttributes.addFlashAttribute("org.springframework.validation.BindingResult.user", result);
            // Add user if invalid data was received
            redirectAttributes.addFlashAttribute("user", user);
            // Redirect back to the form
            return "redirect:/users/add";
        }
        userService.save(user);

        redirectAttributes.addFlashAttribute("flash", new FlashMessage("User successfully added!", FlashMessage.Status.SUCCESS));

        return "redirect:/users";
    }

    // Index for user search and listing
    @GetMapping(path = "/users/search")
    public String userSearch(@RequestParam("name_or_barcode") String nameOrBarcode,Model model) {
        //..
    }


    // Single user page
    @RequestMapping("/users/{userId}")
    public String user(@PathVariable Long userId, Model model) {
        User user = userService.findById(userId);
        model.addAttribute("user", user);
        return "user/details";
    }

    // Delete an existing user
    @RequestMapping(value = "/users/{userId}/delete", method = RequestMethod.POST)
    public String deleteUser(@PathVariable Long userId, RedirectAttributes redirectAttributes) {
        User user = userService.findById(userId);

        if(user.getLoanedBooks().size() > 0) {
            redirectAttributes.addFlashAttribute("flash", new FlashMessage("User cannot be deleted because he/she has borrowed books",FlashMessage.Status.FAILURE));
            return String.format("redirect:/users/%s/edit", userId);
        }
        userService.delete(user);
        redirectAttributes.addFlashAttribute("flash", new FlashMessage("User deleted", FlashMessage.Status.SUCCESS));
        return "redirect:/users";
    }
}

user/form.html

Note: the hidden id field is there.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout :: head"></head>
<body>
<div th:replace="layout :: nav"></div>
<div th:replace="layout :: flash"></div>
<div class="container">
    <form th:action="@{${action}}" method="post" th:object="${user}">
        <input type="hidden" th:field="*{enabled}" />
        <input type="hidden" th:field="*{role}" />
        <input type="hidden" th:field="*{id}" />
        <div class="row">
            <div class="col s12">
                <h2 th:text="${heading}">New User</h2>
            </div>
        </div>
        <div class="divider"></div>
        <div class="row">
            <div class="col s12 l8" th:classappend="${#fields.hasErrors('name')} ? 'error' : ''">
                <input type="text" th:field="*{name}" placeholder="Full Name"/>
                <div class="error-message" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
            </div>
        </div>

        <div class="row">
            <div class="col s12 l8">
                <button th:text="${submit}" type="submit" class="button">Add</button>
                <a th:href="@{/users}" class="button">Cancel</a>
            </div>
        </div>
    </form>
    <div class="row delete" th:if="${user.id != null}">
        <div class="col s12 l8">
            <form th:action="@{|/users/${user.id}/delete|}" method="post">
                <button type="submit" class="button">Delete</button>
            </form>
        </div>
    </div>
</div>
<div th:replace="layout :: scripts"></div>
</body>
</html>

The interesting part is that the enabled field value comes back as 1 which is good.


Solution

  • The field bindings look correct except that the version property must be bound as well to perform an update; otherwise, a creation is performed. Consider adding the following form field binding:

    <input type="hidden" th:field="*{version}" />
    

    This link might be useful: Updating object through CRUD-Repository`s save method changes it's ID. | Treehouse Community.

    Hope this helps.