JPA and UUID

And why @PrePersist is bad for your entity

Author: Anna Skawińska

Wed Oct 24 2018
2
java
1
equals
1
hashcode
1
hibernate
1
jpa
1
persistence
1
uuid

Long story short

Using a manually generated id in your entities, you should initialize it no later than when instantiating the the object to avoid null value comparison in equals() if storing instances in a Set. @PrePersist is too late or you’ll end up with only one persisted object out of the whole collection.

You can still achieve this if using Lombok’s Builder and the @Builder.Default feature.

Best practices in place - what can possibly go wrong?

Manually generated UUID

In our JPA-mapped project, we had Venues and Prices in a One-to-many relationship - each venue can have several prices, keeping them in a Set. Being all good boys and girls here, we had them identified by UUIDs. We generated the UUIDs using the javax.persistence.PrePersist event listener:

public class Price {
  @Id
  @Type(type = "pg-uuid")
  @Column(unique = true, nullable = false, columnDefinition = "uuid")
  private UUID id;

  // other fields...
  
  @PrePersist
  public void prePersist() {
    id = UUID.randomUUID();
  }
  
}

Implemented equals() and hashCode()

As good girls and boys, we also know we should take care of a proper equals() and hashCode implementation, which in case of JPA entities comes down to comparing the unique id:

@Builder
@Entity
public class Price {
// ...
  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Price price = (Price) o;
    return Objects.equals(id, price.id);
  }

  @Override
  public int hashCode() {
    return Objects.hash(id);
  }
 // ...

(Here we’d like to say “hi” to one of our favorite writers Jakub Kubryński )

Let’s get all the credit

So far so good. In the above setting, Venue is the managing entity, and PERSIST is one of the cascaded operations, so adding a few prices to a venue is as simple as:

  gasStation.getPrices().addAll(Set.of(
          Price.builder()
              .priceType(PriceType.REGULAR)
              .price(new BigDecimal("12.345"))
              .venue(gasStation)
              .build(),
          Price.builder()
              .priceType(PriceType.DIESEL)
              .price(new BigDecimal("34.345"))
              .venue(gasStation)
              .build()
      ));

(since we also use the pretty @Builder syntax, generated for us by Lombok).

…but no. A single price will be created correctly, but since Objects.equals() implementation is such that a null equals a null, two freshly created prices with a uuid = null will be treated as equal by the Set implementation and in result… only the one added last will be stored in the database!

This is definitely not what we had in mind.

Side note: We were lucky enough to have discovered this thanks to having some test coverage as well as using Java9’s java.util.ImmutableCollections which gave us the following error, clearly indicating our problem:

java.lang.IllegalArgumentException: duplicate element: Price(id=null, price=12.345, priceType=REGULAR, createdAt=null)

	at java.base/java.util.ImmutableCollections$Set2.<init>(ImmutableCollections.java:380)
	at java.base/java.util.Set.of(Set.java:484) 

Solving the problem

If the @PrePersist phase is too late for generating the UUID for our needs, why not use field initialization, like that?

@Builder
@Entity
public class Price {
  @Id
  @Type(type = "pg-uuid")
  @Column(unique = true, nullable = false, columnDefinition = "uuid")
  private UUID id = UUID.randomUUID();
  }

Well, almost. This would have worked if we hadn’t used the goodness of Lombok's @Builder whose own fields override the Price object’s fields the moment we call build().

The new hope

Fortunately, Lombok didn’t leave us alone in our misery and provided a tool to preserve the initial value of a @Builder - decorated class. It’s the @Builder.Default annotation and we use it like this:

@Builder
@Entity
public class Price {

  @Id
  @Type(type = "pg-uuid")
  @Column(unique = true, nullable = false, columnDefinition = "uuid")
  @Builder.Default
  private UUID id = UUID.randomUUID();
}

Ta-da, persistence functionality is saved, all Price instances get stored, and we can still use the pretty @Builder generated by Lombok.

Conclusions

  1. @PrePersist is too late for programmatically generating an id,
  2. Many thanks to the Lombok team for including the Builder.Default annotation, otherwise we’d have to give up using Builder on this class,
  3. All hail to Java9 and its ImmutableCollections for their validation and nice error messages.

Hope you save some time and trouble with this couple of tips!

Don’t hesitate to reach out.

Loading...
Boldare