Skip to content

Commit

Permalink
Introduces Warning.SURROGATE_OR_BUSINESS_KEY
Browse files Browse the repository at this point in the history
  • Loading branch information
jqno committed Dec 7, 2022
1 parent 7ecafbe commit 19c2b53
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `Warning.SURROGATE_OR_BUSINESS_KEY` for JPA entities that insist on using all fields in `equals()` and `hashCode()`, whether they are `@Id` or not.

### Changed

- Removed duplicated website urls and diagnostic output when using `forPackage()` or `forClasses()` and multiple classes have issues.
Expand Down
8 changes: 5 additions & 3 deletions docs/_manual/09-jpa-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ JPA entities are mutable by design. Since adding `.suppress(Warning.NONFINAL_FIE
JPA entities are also not allowed to be final, and even a final `equals` or `hashCode` method [is problematic](https://stackoverflow.com/questions/6608222/does-a-final-method-prevent-hibernate-from-creating-a-proxy-for-such-an-entity). Therefore, EqualsVerifier will not enforce these for JPA entities, like it normally would. Note that this means that your class will be vulnerable to subclasses [breaking `equals`](/equalsverifier/manual/final).


### Id's
### Ids
By default, EqualsVerifier assumes that your entities have a [business or natural key](https://en.wikipedia.org/wiki/Natural_key). Consequently, all fields that are marked with the `@Id` annotation are assumed not to participate in the class's `equals` and `hashCode` methods. For all other fields, EqualsVerifier behaves as usual.

EqualsVerifier also supports Hibernate's `@NaturalId` annotation. If it detects the presence of this annotation in a class, it will assume that _only_ the fields marked with `@NaturalId` participate in `equals` and `hashCode`, and that all other fields (including the ones marked with `@Id`) do not.
Expand All @@ -21,10 +21,12 @@ When `@NaturalId` is present or when `Warning.SURROGATE_KEY` is suppressed, ther

EqualsVerifier will not only detect these annotations when they are placed on a field, but also when they are placed on the field's corresponding accessor method.

If your class has a business key, but no separate field to serve as `@Id`, you can tell EqualsVerifier by suppressing `Warning.SURROGATE_OR_BUSINESS_KEY`. For instance, if your entity models a person, and the field `socialSecurityNumber` is marked with `@Id`, you can use `Warning.SURROGATE_OR_BUSINESS_KEY` to include `socialSecurityNumber` and other fields like `name` and `birthDate` in `equals` and `hashCode`.

In order to meet the consistency requirements when implementing a class with a surrogate key, some argue that it [is necessary to make the `hashCode` constant](https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/). EqualsVerifier still requires a 'normal' `hashCode` implementation. If you want a constant `hashCode`, you can suppress `Warning.STRICT_HASHCODE`.


### Id's and new objects
### Ids and new objects
A common pattern in JPA when deciding whether two objects are equal, is to look at their fields only if the object hasn't been persisted yet. If it has been persisted, the field has an id, and then the fields are ignored and only the id is used to decide. Such an `equals` method might look like this:

{% highlight java %}
Expand Down Expand Up @@ -124,5 +126,5 @@ EqualsVerifier.forClass(Foo.class)
.verify();
{% endhighlight %}

Of course, you only need to include the annotations that you actually use. If any of the classes you specify isn't an annotation. EqualsVerifier throws an exception.
Of course, you only need to include the annotations that you actually use. If any of the classes you specify isn't an annotation, EqualsVerifier throws an exception.

Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ public enum Warning {
*/
SURROGATE_KEY,

/**
* Disables the check that fields marked with the @Id or @EmbeddedId annotations in JPA entities
* may not be used in the {@code equals} contract.
*
* <p>When this warning is suppressed, all fields will become part of the entity's key, and
* EqualsVerifier will operate as if the entity were a normal class.
*/
SURROGATE_OR_BUSINESS_KEY,

/**
* Disables the check that transient fields not be part of the {@code equals} contract.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ private static <T> Set<String> determineIgnoredFields(
.filter(f -> !fieldHas.apply(f, SupportedAnnotations.NATURALID))
.collect(Collectors.toSet());
}
if (annotationCache.hasClassAnnotation(type, SupportedAnnotations.ID)) {

if (
annotationCache.hasClassAnnotation(type, SupportedAnnotations.ID) &&
!warningsToSuppress.contains(Warning.SURROGATE_OR_BUSINESS_KEY)
) {
if (warningsToSuppress.contains(Warning.SURROGATE_KEY)) {
return actualFields
.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public static void validateWarnings(Set<Warning> warnings) {
warnings.contains(Warning.IDENTICAL_COPY_FOR_VERSIONED_ENTITY),
"you can't suppress Warning.IDENTICAL_COPY_FOR_VERSIONED_ENTITY when Warning.SURROGATE_KEY is also suppressed."
);
validate(
warnings.contains(Warning.SURROGATE_KEY) &&
warnings.contains(Warning.SURROGATE_OR_BUSINESS_KEY),
"you can't suppress Warning.SURROGATE_KEY when Warning.SURROGATE_OR_BUSINESS_KEY is also suppressed."
);
}

public static void validateFields(Set<String> includedFields, Set<String> excludedFields) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ public void succeed_whenOnlyIdFieldIsUsed_givenIdIsAnnotatedWithIdAndSurrogateKe
.verify();
}

@Test
public void succeed_whenAllFieldsAreUsed_givenIdIsAnnotatedWithIdAndJpaKeyWarningIsSuppressed() {
EqualsVerifier
.forClass(JakartaIdValueKeyPerson.class)
.suppress(Warning.SURROGATE_OR_BUSINESS_KEY)
.verify();
EqualsVerifier
.forClass(JakartaIdValueKeyPersonReorderedFields.class)
.suppress(Warning.SURROGATE_OR_BUSINESS_KEY)
.verify();
}

@Test
public void fail_whenIdFieldIsNotUsed_givenIdIsAnnotatedWithIdButIdAnnotationIsIgnored() {
ExpectedException
Expand Down Expand Up @@ -372,6 +384,21 @@ public void fail_whenWarningVersionedEntityIsSuppressed_givenWarningSurrogateKey
);
}

@Test
public void fail_whenWarningSurrogateKeyIsSuppressed_givenWarningSurrogateOrBusinessKeyIsAlsoSuppressed() {
ExpectedException
.when(() ->
EqualsVerifier
.forClass(JakartaIdBusinessKeyPerson.class)
.suppress(Warning.SURROGATE_KEY, Warning.SURROGATE_OR_BUSINESS_KEY)
)
.assertThrows(IllegalStateException.class)
.assertMessageContains(
"Precondition",
"you can't suppress Warning.SURROGATE_KEY when Warning.SURROGATE_OR_BUSINESS_KEY is also suppressed."
);
}

@Test
public void fail_whenANaturalIdAnnotationFromAnotherPackageIsUsed() {
ExpectedException
Expand Down Expand Up @@ -534,6 +561,88 @@ public final int hashCode() {
}
}

static class JakartaIdValueKeyPerson {

@jakarta.persistence.Id
private final UUID id;

private final String socialSecurity;
private final String name;
private final LocalDate birthdate;

public JakartaIdValueKeyPerson(
UUID id,
String socialSecurity,
String name,
LocalDate birthdate
) {
this.id = id;
this.socialSecurity = socialSecurity;
this.name = name;
this.birthdate = birthdate;
}

@Override
public final boolean equals(Object obj) {
if (!(obj instanceof JakartaIdValueKeyPerson)) {
return false;
}
JakartaIdValueKeyPerson other = (JakartaIdValueKeyPerson) obj;
return (
Objects.equals(id, other.id) &&
Objects.equals(socialSecurity, other.socialSecurity) &&
Objects.equals(name, other.name) &&
Objects.equals(birthdate, other.birthdate)
);
}

@Override
public final int hashCode() {
return Objects.hash(id, socialSecurity, name, birthdate);
}
}

static class JakartaIdValueKeyPersonReorderedFields {

private final String socialSecurity;
private final String name;
private final LocalDate birthdate;

@jakarta.persistence.Id
private final UUID id;

public JakartaIdValueKeyPersonReorderedFields(
UUID id,
String socialSecurity,
String name,
LocalDate birthdate
) {
this.id = id;
this.socialSecurity = socialSecurity;
this.name = name;
this.birthdate = birthdate;
}

@Override
public final boolean equals(Object obj) {
if (!(obj instanceof JakartaIdValueKeyPersonReorderedFields)) {
return false;
}
JakartaIdValueKeyPersonReorderedFields other = (JakartaIdValueKeyPersonReorderedFields) obj;
return (
Objects.equals(socialSecurity, other.socialSecurity) &&
Objects.equals(name, other.name) &&
Objects.equals(birthdate, other.birthdate) &&
Objects.equals(id, other.id)
);
}

@Override
public final int hashCode() {
return Objects.hash(socialSecurity, name, birthdate, id);
}
}

static class JakartaEmbeddedIdBusinessKeyPerson {

@jakarta.persistence.EmbeddedId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ public void succeed_whenOnlyIdFieldIsUsed_givenIdIsAnnotatedWithIdAndSurrogateKe
.verify();
}

@Test
public void succeed_whenAllFieldsAreUsed_givenIdIsAnnotatedWithIdAndJpaKeyWarningIsSuppressed() {
EqualsVerifier
.forClass(JpaIdValueKeyPerson.class)
.suppress(Warning.SURROGATE_OR_BUSINESS_KEY)
.verify();
EqualsVerifier
.forClass(JpaIdValueKeyPersonReorderedFields.class)
.suppress(Warning.SURROGATE_OR_BUSINESS_KEY)
.verify();
}

@Test
public void fail_whenIdFieldIsNotUsed_givenIdIsAnnotatedWithIdButIdAnnotationIsIgnored() {
ExpectedException
Expand Down Expand Up @@ -380,6 +392,21 @@ public void fail_whenWarningVersionedEntityIsSuppressed_givenWarningSurrogateKey
);
}

@Test
public void fail_whenWarningSurrogateKeyIsSuppressed_givenWarningSurrogateOrBusinessKeyIsAlsoSuppressed() {
ExpectedException
.when(() ->
EqualsVerifier
.forClass(JpaIdBusinessKeyPerson.class)
.suppress(Warning.SURROGATE_KEY, Warning.SURROGATE_OR_BUSINESS_KEY)
)
.assertThrows(IllegalStateException.class)
.assertMessageContains(
"Precondition",
"you can't suppress Warning.SURROGATE_KEY when Warning.SURROGATE_OR_BUSINESS_KEY is also suppressed."
);
}

@Test
public void fail_whenAnIdAnnotationFromAnotherPackageIsUsed() {
ExpectedException
Expand Down Expand Up @@ -550,6 +577,88 @@ public final int hashCode() {
}
}

static class JpaIdValueKeyPerson {

@Id
private final UUID id;

private final String socialSecurity;
private final String name;
private final LocalDate birthdate;

public JpaIdValueKeyPerson(
UUID id,
String socialSecurity,
String name,
LocalDate birthdate
) {
this.id = id;
this.socialSecurity = socialSecurity;
this.name = name;
this.birthdate = birthdate;
}

@Override
public final boolean equals(Object obj) {
if (!(obj instanceof JpaIdValueKeyPerson)) {
return false;
}
JpaIdValueKeyPerson other = (JpaIdValueKeyPerson) obj;
return (
Objects.equals(id, other.id) &&
Objects.equals(socialSecurity, other.socialSecurity) &&
Objects.equals(name, other.name) &&
Objects.equals(birthdate, other.birthdate)
);
}

@Override
public final int hashCode() {
return Objects.hash(id, socialSecurity, name, birthdate);
}
}

static class JpaIdValueKeyPersonReorderedFields {

private final String socialSecurity;
private final String name;
private final LocalDate birthdate;

@Id
private final UUID id;

public JpaIdValueKeyPersonReorderedFields(
UUID id,
String socialSecurity,
String name,
LocalDate birthdate
) {
this.id = id;
this.socialSecurity = socialSecurity;
this.name = name;
this.birthdate = birthdate;
}

@Override
public final boolean equals(Object obj) {
if (!(obj instanceof JpaIdValueKeyPersonReorderedFields)) {
return false;
}
JpaIdValueKeyPersonReorderedFields other = (JpaIdValueKeyPersonReorderedFields) obj;
return (
Objects.equals(socialSecurity, other.socialSecurity) &&
Objects.equals(name, other.name) &&
Objects.equals(birthdate, other.birthdate) &&
Objects.equals(id, other.id)
);
}

@Override
public final int hashCode() {
return Objects.hash(socialSecurity, name, birthdate, id);
}
}

static class JpaEmbeddedIdBusinessKeyPerson {

@EmbeddedId
Expand Down

0 comments on commit 19c2b53

Please sign in to comment.