Search code examples
spring-bootspring-mvcspring-data-jpamany-to-manythymeleaf

How to work with a many-to-many relationship with extra attributes using Spring Boot, Spring MVC, Spring Data JPA and Thymeleaf


I'm tryingto build an insert, update and delete forms for a many-to-many relationship with one extra attribute, but I'm having some problems. When I use a simple @ManyToMany annotation everything works since Spring MVC and Thyemeleaf automatically take care of everything. Now, I need to have an extra attribute in the join table/entity. This attribute will be managed by the business logic and it doesn't need to be present in the view. I'm implementing my scenario creating manually the join entity rather than using the @Embedded annotation.

What I have so far will be shown below. Note that I'm omitting the import statements and I'm reproducing a very simpler version that I actually have.

Entities

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode( onlyExplicitlyIncluded = true )
public class Race {
    
    @Id
    @GeneratedValue( strategy = GenerationType.IDENTITY )
    @EqualsAndHashCode.Include
    private Long id;
    
    @NotNull
    @NotEmpty
    private String name;
    
    @OneToMany( mappedBy = "race", cascade = CascadeType.ALL, orphanRemoval = true )
    private Set<TruckFromRace> trucksFromRace;
    
}

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode( onlyExplicitlyIncluded = true )
public class Truck {
    
    @Id
    @GeneratedValue( strategy = GenerationType.IDENTITY )
    @EqualsAndHashCode.Include
    private Long id;
    
    @NotNull
    @Min( value = 1 )
    private Integer number;
    
    @OneToMany( mappedBy = "truck", cascade = CascadeType.ALL, orphanRemoval = true )
    private Set<TruckFromRace> trucksFromRace;
    
}

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode( onlyExplicitlyIncluded = true )
public class TruckFromRace {
    
    @Id
    @GeneratedValue( strategy = GenerationType.IDENTITY )
    @EqualsAndHashCode.Include
    private Long id;
    
    @ManyToOne
    @JoinColumn( name = "truck_id" )
    @ToString.Exclude
    @JsonProperty( access = Access.WRITE_ONLY )
    private Truck truck;
    
    @ManyToOne
    @JoinColumn( name = "race_id" )
    @ToString.Exclude
    @JsonProperty( access = Access.WRITE_ONLY )
    private Race race;
    
    @NotNull
    private Integer currentLap = 0;
    
}

Repositories

public interface RaceRepository extends CrudRepository<Race, Long> {
    
}

public interface TruckFromRaceRepository extends CrudRepository<TruckFromRace, Long> {
    
}

public interface TruckRepository extends CrudRepository<Truck, Long> {
    
}

Controller

@Controller
@RequestMapping( "/races" )
public class RaceController {
    
    @Autowired
    private RaceRepository raceRepo;
    
    @Autowired
    private TruckRepository truckRepo;
    
    @Autowired
    private TruckFromRaceRepository trucksFromRaceRepo;
    
    @GetMapping( "/list" )
    public String listar( Model model ) {
        model.addAttribute( "races", raceRepo.findAll() );
        return "/forms/races/list";
    }
    
    @GetMapping( "/prepareInsert" )
    public String prepareInsert( Race race, Model model ) {
        model.addAttribute( "trucks", truckRepo.findAll() );
        return "/forms/races/insert";
    }
    
    @PostMapping( "/insert" )
    public String insert( @Valid Race race,
                          BindingResult result, 
                          Model model,
                          @RequestParam( required = false ) Long[] trucksFromRace ) {
        
        if ( result.hasErrors() ) {
            model.addAttribute( "trucks", truckRepo.findAll() );
            return "/forms/races/insert";
        }
        
        raceRepo.save( race );
        
        if ( trucksFromRace != null ) {
            
            for ( Long truckId : trucksFromRace ) {
                
                Truck t = truckRepo.findById( truckId ).get();
                
                TruckFromRace tr = new TruckFromRace();
                tr.setTruck( t );
                tr.setRace( race );
                
                trucksFromRaceRepo.save( tr );
                
            }
            
        }
        
        return "redirect:/races/list";
        
    }
    
    @GetMapping( "/prepareUpdate/{id}" )
    public String prepareUpdate( @PathVariable( "id" ) Long id, 
                                 Model model ) {
        
        Race race = raceRepo.findById( id )
                .orElseThrow( () -> new IllegalArgumentException( "Invalid id:" + id ) );
        
        model.addAttribute( "trucks", truckRepo.findAll() );
        model.addAttribute( "race", race );
        
        return "/forms/races/update";
        
    }
    
    @PostMapping( "/update/{id}" )
    public String update( @PathVariable( "id" ) Long id, 
                          @Valid Race race, 
                          BindingResult result,
                          Model model,
                          @RequestParam( required = false ) Long[] trucksFromRace ) {
        
        if ( result.hasErrors() ) {
            model.addAttribute( "trucks", truckRepo.findAll() );
            race.setId( id );
            return "/forms/races/update";
        }
        
        // remove all trucks from this race
        // only works if the if above is commented
        race.getTrucksFromRace().clear();
        raceRepo.save( race );
        
        // try to associate new ones
        /*if ( trucksFromRace != null ) {
            
            for ( Long truckId : trucksFromRace ) {
                
                Truck t = truckRepo.findById( truckId ).get();
                
                TruckFromRace tr = new TruckFromRace();
                tr.setTruck( t );
                tr.setRace( race );
                
                trucksFromRaceRepo.save( tr );
                
            }
            
        }*/

        return "redirect:/races/list";
        
    }
    
}

Templates

forms/races/list.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>
    <title>Races</title>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"/>  
</head>

<body>
    
    <h4>Races</h4>
        
    <table>
        <thead>
            <tr>
                <th>Name</th>
                <th>Update</th>
                <th>Delete</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="race : ${races}">
                <td th:text="${race.name}"></td>
                <td><a th:href="@{/races/prepareUpdate/{id}(id=${race.id})}">Update</a></td>
                <td><a th:href="@{/races/prepareDelete/{id}(id=${race.id})}">Delete</a></td>
            </tr>
        </tbody>
    </table>
    
    <a href="/races/prepareInsert">New Race</a>
    <br/>
    <a href="/index">Index</a>
    
</body>

</html>

forms/races/insert.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>
    <title>New Race</title>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"/>  
</head>

<body>
        
    <h4>New Race</h4>

    <form action="#" th:action="@{/races/insert}" th:object="${race}" method="post">
    
        <div>
            <label for="name">Name</label>
            <input type="text" th:field="*{name}" id="name" placeholder="Name"/>
            <small th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></small>
        </div>
        
        <h4>Trucks</h4>
        <table>
            <thead>
                <tr>
                    <th>Number</th>
                    <th>Will compete</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="truck : ${trucks}">
                    <td th:text="${truck.number}"></td>
                    <td>
                        <input class="form-check-input" th:field="*{trucksFromRace}" type="checkbox" th:value="${truck.id}"/>
                    </td>
                </tr>
            </tbody>
        </table>
        
        <input type="submit" value="Save"/>
        <br/>
        <a href="/races/list">Back</a>
                
    </form>
    
</body>

</html>

forms/races/update.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>
    <title>New Race</title>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"/>  
</head>

<body>
        
    <h4>Update Race</h4>


    <form action="#" th:action="@{/races/update/{id}(id=${race.id})}" th:object="${race}" method="post">
    
        <div>
            <label for="name">Name</label>
            <input type="text" th:field="*{name}" id="name" placeholder="Name"/>
            <small th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></small>
        </div>
        
        <h4>Trucks</h4>
        <table>
            <thead>
                <tr>
                    <th>Number</th>
                    <th>Will compete</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="truck : ${trucks}">
                    <td th:text="${truck.number}"></td>
                    <td>
                        <input class="form-check-input" th:field="*{trucksFromRace}" type="checkbox" th:value="${truck.id}"/>
                    </td>
                </tr>
            </tbody>
        </table>
        
        <input type="submit" value="Save"/>
        <br/>
        <a href="/races/list">Back</a>
                
    </form>
    
</body>

</html>

My insertion is working fine, althoug I think that the controller code could be simpler, but I have two problems with the update operation. I'm omitting the delete operation because it will be based in the update operation.

  1. When I try to update a race, clicking in the update link in the list of races, the form is being loaded erroneously. Let me explain. Firstly I save a new race and it works as expected:

enter image description here

enter image description here

But, when I try to update it, I have this erroneous result in my view:

enter image description here

So, I would like to know what I need to do to solve this.

  1. My second problem is related to the update method in the RaceController class. What I'm experiencing is described in some comments inside the code. I'm pretty rusty with JPA... My last time doing these kinds of implementations in real scenarios was more than 10 years ago.

Thanks!


Solution

  • Many to many link table only take care of two table in many to many , it may not handle extra attribute.

    In my way the possible solution is to create/entity Which hold two tables and one attribute.

    Previous: Table1 and Table2 in m2m relationship.

    Solution Table 3 has one to many relationship with table 1 and table 2 , also table 3 has an other extra attribute.