In this post we will take a look at couple of ways how many-to-many relationships, which have additional information, can be mapped to entities using JPA. @ManyToMany annotation cannot be applied in such cases as it does not allow to map additional information from the join table.
To show these approaches let's consider such small film rental database schema:
Using @ElementCollection and Map for mapping many-to-many relationship with additional information
First thing first - we need to create a movie! After all the work on the film is finished we need to store it in the database somehow. Mapping actors and films is easy. An example of such mapping can be next:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* A base entity which has an id and uuid for ease of definig hash/equals | |
* for an entity. | |
*/ | |
@MappedSuperclass | |
public abstract class BaseEntity { | |
@Id | |
@SequenceGenerator(name = "sequence", sequenceName = "film_actor_sequence", allocationSize = 1) | |
@GeneratedValue(generator = "sequence") | |
private Integer id; | |
private String uuid = UUID.randomUUID().toString(); | |
// id getter and hash/equals ommitted | |
} | |
// Film mapping | |
@Entity | |
@Table(name = "film") | |
public class Film extends BaseEntity { | |
private String name; | |
private Duration length; | |
} | |
// Actor mapping | |
@Entity | |
@Table(name = "actor") | |
public class Actor extends BaseEntity { | |
private Name name; | |
} | |
// And simple embeddable for Name: | |
@Embeddable | |
@Access(AccessType.FIELD) | |
public class Name { | |
@Column(name = "first_name") | |
private String firstName; | |
@Column(name = "last_name") | |
private String lastName; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Film entity updated with the {@link ElementCollection} Map to store | |
* film_to_actor relationship. | |
*/ | |
@Entity | |
@Table(name = "film") | |
public class Film extends BaseEntity { | |
private String name; | |
private Duration length; | |
@ElementCollection | |
@CollectionTable(name = "film_to_actor", | |
joinColumns = @JoinColumn(name = "film_id") | |
) | |
@MapKeyJoinColumn(name = "actor_id") | |
private Map<Actor, CharacterInformation> characterInformation = new HashMap<>(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* An embeddable to store character information which consists of | |
* first and last names represented by {@link Name} embeddable and | |
* a boolean value if it is a main character of the movie or not. | |
*/ | |
@Embeddable | |
public class CharacterInformation implements Serializable { | |
private Name name; | |
@Column(name = "main_character") | |
private boolean mainCharacter; | |
} |
If we would like to rather have information about in which films an actor played which character, we would place a Map in actor entity, where a film would be a map key and character information would be a map value.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Shows an example of using an element collection mapping on the actor side | |
* of the relationship. | |
*/ | |
@Entity | |
@Table(name = "actor") | |
public class Actor extends BaseEntity { | |
private Name name; | |
@ElementCollection | |
@CollectionTable(name = "film_to_actor", | |
joinColumns = @JoinColumn(name = "actor_id") | |
) | |
@MapKeyJoinColumn(name = "film_id") | |
private Map<Film, CharacterInformation> characterInformation = new HashMap<>(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Actor actor1 = Actor.with( Name.from( "John", "Doe" ) ); | |
Actor actor2 = Actor.with( Name.from( "Jane", "Doe" ) ); | |
// need to persist actors separately as operation will not be cascaded | |
entityManager.persist( actor1 ); | |
entityManager.persist( actor2 ); | |
Film film = Film.Builder.instance() | |
.title( "Best film ever" ) | |
.lenth( Duration.ofHours( 1 ).plusMinutes( 20 ) ) | |
.create(); | |
film.addCharacter( | |
actor1, | |
CharacterInformation.Builder.instance() | |
.withName( Name.from( "John", "Smith" ) ) | |
.isMainCharacter( true ) | |
.create() | |
); | |
film.addCharacter( | |
actor2, | |
CharacterInformation.Builder.instance() | |
.withName( Name.from( "Jane", "Smith" ) ) | |
.isMainCharacter( true ) | |
.create() | |
); | |
entityManager.persist( film ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Film extends BaseEntity { | |
// ... | |
public Film addCharacter(Actor actor, CharacterInformation information) { | |
characterInformation.put( actor, information ); | |
return this; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Film extends BaseEntity { | |
// ... | |
/** | |
* @return a collection of actors that play in the film | |
*/ | |
public Collection<Actor> listActors() { | |
return characterInformation.keySet().stream() | |
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) ); | |
} | |
/** | |
* @return a collection of characters in the film | |
*/ | |
public Collection<CharacterInformation> listCharacters() { | |
return characterInformation.values().stream() | |
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) ); | |
} | |
/** | |
* Removes an actor form the film. | |
* | |
* @param actor an actor to remove | |
* | |
* @return {@link CharacterInformation} wrapped in {@link Optional}. Will be empty if there was | |
* no such actor in the film, will contain the actor's role otherwise. | |
*/ | |
public Optional<CharacterInformation> removeActor(Actor actor) { | |
return Optional.ofNullable( characterInformation.remove( actor ) ); | |
} | |
} |
Using @ManyToOne/@OneToMany and an entity to map many-to-many relationship with additional information
So we have a film and now we want to rent and watch it. If the film is good it might be rented multiple times, hence the difference in the schema. We need to be able to store a relation between same pairs of films and customers multiple times. Therefore Map will not suite our needs. Also it is worth mentioning that the renting relation itself (rental table), in some sense, represents an entity, which we might be interested in as an event of renting a film. For example we might like to report on how frequently some film is rented etc.
We already have film mapped, so we need only to map a customer and rental relationship. As we can see from our database diagram customer is basically the same as actor table, thus the mapping of basic fields for it will look the same. What is interesting here is how to map a rental relationship.
As you might already figured out we need to create a Rental entity first:
As you can see @ManyToOne mappings were used on film and customer. Now we can reference a collection of such rentals in the customer entity:
This results in next class diagram:
Let's rent a film now:
Similarly, we don't want to expose a collection of rentals "as is" from the customer entity. Instead business methods are provided in the Customer class to work with rented films.
It is worth mentioning that if we modeled the schema slightly differently we would also be able to use a Map and @OneToMany to map rentals. Assuming that at a given moment of time, a customer can rent just one film, we would be able to use a Map<LocalDateTime, Film>. In this case rental table would be designed as:
As mentioned in the comments in the SQL snippet - we could also get rid of the id column and create a composite key from all columns that remains.
And the mapping in the customer entity in such case would be:
Personally I would suggest using maps (either as element collections, or mapped as @OneToMany) in those cases where the relationship table is more representing a connection between other entities with some additional information, rather than being an entity by itself. In this case you will have a smaller amount of entity classes and your model will look cleaner to the end user, if you hide the work with maps behind entity's business methods. Another benefit of using maps in modeling this kind of relationships comes from it's support of unique keys by design. On the other hand if the many-to-many relationship feels more like an entity itself and might be used in application outside of the entities connected by this relationship - then model it like an entity.
All the code samples can be found on GitHub.
To be continued ...
We already have film mapped, so we need only to map a customer and rental relationship. As we can see from our database diagram customer is basically the same as actor table, thus the mapping of basic fields for it will look the same. What is interesting here is how to map a rental relationship.
As you might already figured out we need to create a Rental entity first:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Entity | |
@Table(name = "rental") | |
public class Rental extends BaseEntity { | |
@ManyToOne | |
private Film film; | |
@ManyToOne | |
private Customer customer; | |
private LocalDateTime date; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Entity | |
@Table(name = "customer") | |
public class Customer extends BaseEntity { | |
private Name name; | |
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL) | |
private Set<Rental> rentals = new HashSet<>(); | |
} |
Let's rent a film now:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public void rentFilm() { | |
Customer customer = Customer.with( Name.from( "Charley", "Brown" ) ); | |
// Film with id = 9 is prepopulated in the test data | |
Film film = entityManager.find( Film.class, 9 ); | |
customer.rentFilm( film ); | |
entityManager.persist( customer ); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class Customer extends BaseEntity { | |
//... | |
/** | |
* Will rent a {@code film} at current date and time. | |
* | |
* @param film a film to rent | |
*/ | |
public Customer rentFilm(Film film) { | |
rentals.add( | |
Rental.Builder.instance() | |
.film( film ) | |
.on( LocalDateTime.now() ) | |
.byCustomer( this ) | |
.rent() | |
); | |
return this; | |
} | |
/** | |
* @param start a start date of a period | |
* @param end an end date of the period | |
* | |
* @return a collection of distinct films rented by current customer during a time period | |
* between {@code start} and {@code end} dates | |
*/ | |
public Collection<Film> uniqueRentedFilms(LocalDateTime start, LocalDateTime end) { | |
return rentals.stream() | |
.filter( rental -> rental.isRentedBetween( start, end ) ) | |
.map( Rental::getFilm ) | |
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) ); | |
} | |
/** | |
* @return a collection of distinct films rented for the whole time | |
*/ | |
public Collection<Film> uniqueRentedFilmsForAllTime() { | |
return rentals.stream() | |
.map( Rental::getFilm ) | |
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) ); | |
} | |
/** | |
* @return rental information represented by a map where rented {@link Film} is a key | |
* and a list of dates when the {@code film} was rented as a value. | |
*/ | |
public Map<Film, List<LocalDateTime>> rentalInformation() { | |
return rentals.stream() | |
.collect( | |
Collectors.collectingAndThen( | |
Collectors.groupingBy( | |
Rental::getFilm, | |
Collectors.mapping( | |
Rental::getDate, | |
Collectors.collectingAndThen( | |
Collectors.toList(), | |
Collections::unmodifiableList | |
) | |
) | |
), | |
Collections::unmodifiableMap | |
) | |
); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- auto-generated definition | |
CREATE TABLE rental | |
( | |
-- having such primary key is not mandatory. we could use a composite one | |
-- constructed from all three columns - date, film_id, actor_id as in our case | |
-- such combination will be unique. | |
id INTEGER DEFAULT nextval( 'film_actor_sequence' :: REGCLASS ) NOT NULL | |
CONSTRAINT rental_pkey | |
PRIMARY KEY, | |
-- we will not use uuid in this case of mapping as rental will not be an entity but just | |
-- a join table. All other columns remains as previously | |
-- uuid VARCHAR(255) NOT NULL, | |
date TIMESTAMP, | |
customer_id INTEGER | |
CONSTRAINT rental_customer_id_fkey | |
REFERENCES customer, | |
film_id INTEGER | |
CONSTRAINT rental_film_id_fkey | |
REFERENCES film | |
); |
As mentioned in the comments in the SQL snippet - we could also get rid of the id column and create a composite key from all columns that remains.
And the mapping in the customer entity in such case would be:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Entity | |
@Table(name = "customer") | |
public class Customer extends BaseEntity { | |
private Name name; | |
@OneToMany(cascade = CascadeType.ALL) | |
@JoinTable(name = "rental", | |
joinColumns = @JoinColumn(name ="customer_id"), | |
inverseJoinColumns = @JoinColumn(name = "film_id") | |
) | |
@MapKeyColumn(name = "date") | |
private Map<LocalDateTime, Film> rentalsByDateTime = new HashMap<>(); | |
} |
Conclusions
A multiple possible ways to map many-to-many relationships using just JPA was presented as well as the way how to hide the plain relationship collection operations for users behind business methods on entities. Modeling the data is a very interesting topic and there are a lot of different ways you can do such kind of mappings. Choose the one that suites your needs the best.Personally I would suggest using maps (either as element collections, or mapped as @OneToMany) in those cases where the relationship table is more representing a connection between other entities with some additional information, rather than being an entity by itself. In this case you will have a smaller amount of entity classes and your model will look cleaner to the end user, if you hide the work with maps behind entity's business methods. Another benefit of using maps in modeling this kind of relationships comes from it's support of unique keys by design. On the other hand if the many-to-many relationship feels more like an entity itself and might be used in application outside of the entities connected by this relationship - then model it like an entity.
All the code samples can be found on GitHub.
To be continued ...
static factories with parameters is one good approach, I appreciate that.
ReplyDeleteAnd the mapping is very good to, awesome post.
Thank you.
From Brazil.
I have one question. In one book I've read about Serialization Proxies, where the class implements Serializable, and use static methods: writeReplace, and readResolve. Do you know something about this? Why nobody uses in Serialization examples like this?
ReplyDeleteThanks.