HHH-15425 Improved association @NotFound documentation

This commit is contained in:
Andrea Boriero 2022-08-01 17:07:32 +02:00 committed by Andrea Boriero
parent 29607ce760
commit 57c1a029d4
2 changed files with 285 additions and 78 deletions

View File

@ -176,7 +176,7 @@ From a relational database point of view, the underlying schema is identical to
as the client-side controls the relationship based on the foreign key column.
But then, it's unusual to consider the `Phone` as a client-side and the `PhoneDetails` as the parent-side because the details cannot exist without an actual phone.
A much more natural mapping would be if the `Phone` were the parent-side, therefore pushing the foreign key into the `PhoneDetails` table.
A much more natural mapping would be the `Phone` were the parent-side, therefore pushing the foreign key into the `PhoneDetails` table.
This mapping requires a bidirectional `@OneToOne` association as you can see in the following example:
[[associations-one-to-one-bidirectional]]
@ -392,8 +392,9 @@ Because this mapping is formed out of two bidirectional associations, the helper
[NOTE]
====
The aforementioned example uses a Hibernate specific mapping for the link entity since JPA doesn't allow building a composite identifier out of multiple `@ManyToOne` associations.
For more details, see the <<chapters/domain/identifiers.adoc#identifiers-composite-associations,Composite identifiers - associations>> section.
The aforementioned example uses a Hibernate-specific mapping for the link entity since JPA doesn't allow building a composite identifier out of multiple `@ManyToOne` associations.
For more details, see the <<chapters/domain/identifiers.adoc#identifiers-composite-associations,composite identifiers with associations>> section.
====
The entity state transitions are better managed than in the previous bidirectional `@ManyToMany` case.
@ -415,15 +416,38 @@ include::{extrasdir}/associations-many-to-many-bidirectional-with-link-entity-li
There is only one delete statement executed because, this time, the association is controlled by the `@ManyToOne` side which only has to monitor the state of the underlying foreign key relationship to trigger the right DML statement.
[[associations-not-found]]
==== `@NotFound` association mapping
==== `@NotFound`
When dealing with associations which are not enforced by a Foreign Key,
it's possible to bump into inconsistencies if the child record cannot reference a parent entity.
When dealing with associations which are not enforced by a physical foreign-key, it is possible
for a non-null foreign-key value to point to a non-existent value on the associated entity's table.
By default, Hibernate will complain whenever a child association references a non-existing parent record.
However, you can configure this behavior so that Hibernate can ignore such an Exception and simply assign `null` as a parent object referenced.
[WARNING]
====
Not enforcing physical foreign-keys at the database level is highly discouraged.
====
Hibernate provides support for such models using the `@NotFound` annotation, which accepts a
`NotFoundAction` value which indicates how Hibernate should behave when such broken foreign-keys
are encountered -
EXCEPTION:: (default) Hibernate will throw an exception (`FetchNotFoundException`)
IGNORE:: the association will be treated as `null`
Both `@NotFound(IGNORE)` and `@NotFound(EXCEPTION)` cause Hibernate to assume that there is
no physical foreign-key.
`@ManyToOne` and `@OneToOne` associations annotated with `@NotFound` are always fetched eagerly even
if the `fetch` strategy is set to `FetchType.LAZY`.
[TIP]
====
If the application itself manages the referential integrity and can guarantee that there are no
broken foreign-keys, `jakarta.persistence.ForeignKey(NO_CONSTRAINT)` can be used instead.
This will force Hibernate to not export physical foreign-keys, but still behave as if there is
in terms of avoiding the downsides to `@NotFound`.
====
To ignore non-existing parent entity references, even though not really recommended, it's possible to use the annotation `org.hibernate.annotation.NotFound` annotation with a value of `org.hibernate.annotations.NotFoundAction.IGNORE`.
Considering the following `City` and `Person` entity mappings:
@ -439,7 +463,7 @@ include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-domain-model-
If we have the following entities in our database:
[[associations-not-found-persist-example]]
.`@NotFound` mapping example
.`@NotFound` persist example
====
[source,java]
----
@ -450,32 +474,87 @@ include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-persist-examp
When loading the `Person` entity, Hibernate is able to locate the associated `City` parent entity:
[[associations-not-found-find-example]]
.`@NotFound` find existing entity example
.`@NotFound` - find existing entity example
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-find-example,indent=0]
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-find-baseline,indent=0]
----
====
However, if we change the `cityName` attribute to a non-existing city:
However, if we break the foreign-key:
[[associations-not-found-non-existing-persist-example]]
.`@NotFound` change to non-existing City example
.Break the foreign-key
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-non-existing-persist-example,indent=0]
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-break-fk,indent=0]
----
====
Hibernate is not going to throw any exception, and it will assign a value of `null` for the non-existing `City` entity reference:
[[associations-not-found-non-existing-find-example]]
.`@NotFound` find non-existing City example
.`@NotFound` - find non-existing City example
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-non-existing-find-example,indent=0]
----
====
`@NotFound` also affects how the association is treated as "implicit joins" in HQL and Criteria.
When there is a physical foreign-key, Hibernate can safely assume that the value in the foreign-key's
key-column(s) will match the value in the target-column(s) because the database makes sure that
is the case. However, `@NotFound` forces Hibernate to perform a physical join for implicit joins
when it might not be needed otherwise.
Using the `Person` / `City` model, consider the query `from Person p where p.city.id is null`.
Normally Hibernate would not need the join between the `Person` table and the `City` table because
a physical foreign-key would ensure that any non-null value in the `Person.cityName` column
has a matching non-null value in the `City.name` column.
However, with `@NotFound` mappings it is possible to have a broken association because there is no
physical foreign-key enforcing the relation. As seen in <<associations-not-found-non-existing-persist-example>>,
the `Person.cityName` column for John Doe has been changed from "New York" to "Atlantis" even though
there is no `City` in the database named "Atlantis". Hibernate is not able to trust the referring
foreign-key value ("Atlantis") has a matching target value, so it must join to the `City` table to
resolve the `city.id` value.
[[associations-not-found-implicit-join-example]]
.Implicit join example
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-implicit-join-example,indent=0]
----
====
Neither result includes a match for "John Doe" because the inner-join filters out that row.
Hibernate does support a means to refer specifically to the key column (`Person.cityName`) in a query
using the special `fk(..)` function. E.g.
[[associations-not-found-implicit-join-example]]
.Implicit join example
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-fk-function-example,indent=0]
----
====
With Hibernate Criteria it is possible to use `Projections.fk(...)` to select the foreign key value of an association
and `Restrictions.fkEq(...)`, `Restrictions.fkNe(...)`, `Restrictions.fkIsNotNull(...)` and ``Restrictions.fkIsNull(...)`, E.g.
[[associations-not-found-implicit-join-example]]
.Implicit join example
====
[source,java]
----
include::{sourcedir}/NotFoundTest.java[tags=associations-not-found-fk-criteria-example,indent=0]
----
====

View File

@ -1,22 +1,37 @@
package org.hibernate.userguide.associations;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import javax.persistence.ConstraintMode;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.ForeignKey;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import org.hibernate.Criteria;
import org.hibernate.Session;
import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;
import org.hibernate.criterion.ProjectionList;
import org.hibernate.criterion.Projections;
import org.hibernate.criterion.Restrictions;
import org.hibernate.jpa.test.BaseEntityManagerFunctionalTestCase;
import org.hibernate.testing.jdbc.SQLStatementInterceptor;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hibernate.testing.transaction.TransactionUtil.doInJPA;
import static org.hibernate.testing.transaction.TransactionUtil2.inTransaction;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
/**
@ -24,6 +39,13 @@ import static org.junit.Assert.assertNull;
*/
public class NotFoundTest extends BaseEntityManagerFunctionalTestCase {
private SQLStatementInterceptor sqlStatementInterceptor;
@Override
protected void addConfigOptions(Map options) {
sqlStatementInterceptor = new SQLStatementInterceptor( options );
}
@Override
protected Class<?>[] getAnnotatedClasses() {
return new Class<?>[] {
@ -32,75 +54,182 @@ public class NotFoundTest extends BaseEntityManagerFunctionalTestCase {
};
}
@Test
public void test() {
doInJPA( this::entityManagerFactory, entityManager -> {
@Before
public void createTestData() {
inTransaction( entityManagerFactory(), (entityManager) -> {
//tag::associations-not-found-persist-example[]
City _NewYork = new City();
_NewYork.setName( "New York" );
entityManager.persist( _NewYork );
City newYork = new City( 1, "New York" );
entityManager.persist( newYork );
Person person = new Person();
person.setId( 1L );
person.setName( "John Doe" );
person.setCityName( "New York" );
Person person = new Person( 1, "John Doe", newYork );
entityManager.persist( person );
//end::associations-not-found-persist-example[]
} );
}
doInJPA( this::entityManagerFactory, entityManager -> {
//tag::associations-not-found-find-example[]
Person person = entityManager.find( Person.class, 1L );
assertEquals( "New York", person.getCity().getName() );
//end::associations-not-found-find-example[]
//tag::associations-not-found-non-existing-persist-example[]
person.setCityName( "Atlantis" );
//end::associations-not-found-non-existing-persist-example[]
@After
public void dropTestData() {
inTransaction( entityManagerFactory(), (em) -> {
em.createQuery( "delete Person" ).executeUpdate();
em.createQuery( "delete City" ).executeUpdate();
} );
}
doInJPA( this::entityManagerFactory, entityManager -> {
@Test
public void test() {
doInJPA(this::entityManagerFactory, entityManager -> {
//tag::associations-not-found-find-baseline[]
Person person = entityManager.find(Person.class, 1);
assertEquals("New York", person.getCity().getName());
//end::associations-not-found-find-baseline[]
});
breakForeignKey();
doInJPA(this::entityManagerFactory, entityManager -> {
//tag::associations-not-found-non-existing-find-example[]
Person person = entityManager.find( Person.class, 1L );
Person person = entityManager.find(Person.class, 1);
assertEquals( "Atlantis", person.getCityName() );
assertNull( null, person.getCity() );
assertNull(null, person.getCity());
//end::associations-not-found-non-existing-find-example[]
});
}
private void breakForeignKey() {
inTransaction( entityManagerFactory(), (em) -> {
//tag::associations-not-found-break-fk[]
// the database allows this because there is no physical foreign-key
em.createQuery( "delete City" ).executeUpdate();
//end::associations-not-found-break-fk[]
} );
}
@Test
public void queryTest() {
breakForeignKey();
inTransaction( entityManagerFactory(), (entityManager) -> {
//tag::associations-not-found-implicit-join-example[]
final List<Person> nullResults = entityManager
.createQuery( "from Person p where p.city.id is null", Person.class )
.list();
assertThat( nullResults.size(), is( 0 ) );
final List<Person> nonNullResults = entityManager
.createQuery( "from Person p where p.city.id is not null", Person.class )
.list();
assertThat( nonNullResults.size(), is( 0 ) );
//end::associations-not-found-implicit-join-example[]
} );
}
@Test
public void queryTestFk() {
breakForeignKey();
inTransaction( entityManagerFactory(), (entityManager) -> {
sqlStatementInterceptor.clear();
//tag::associations-not-found-fk-function-example[]
final List<String> nullResults = entityManager
.createQuery( "select p.name from Person p where fk( p.city ) is null", String.class )
.list();
assertThat( nullResults.size(), is( 0 ) );
final List<String> nonNullResults = entityManager
.createQuery( "select p.name from Person p where fk( p.city ) is not null", String.class )
.list();
assertThat( nonNullResults.size(), is( 1 ) );
assertThat( nonNullResults.get( 0 ), is( "John Doe" ) );
//end::associations-not-found-fk-function-example[]
// In addition, make sure that the two executed queries do not create a join
assertThat( sqlStatementInterceptor.getSqlQueries().size(), is(2) );
assertFalse( sqlStatementInterceptor.getSqlQueries().get( 0 ).contains( " join " ) );
assertFalse( sqlStatementInterceptor.getSqlQueries().get( 1 ).contains( " join " ) );
} );
}
@Test
public void cirteriaTestFk() {
breakForeignKey();
inTransaction( entityManagerFactory(), (entityManager) -> {
sqlStatementInterceptor.clear();
Session session = entityManager.unwrap( Session.class );
//tag::associations-not-found-fk-criteria-example[]
Criteria criteria = session.createCriteria( Person.class );
ProjectionList projList = Projections.projectionList();
projList.add( Projections.property( "name" ) );
criteria.setProjection( projList );
criteria.add( Restrictions.fkIsNull( "city" ) );
final List<Integer> nullResults = criteria.list();
assertThat( nullResults.size(), is( 0 ) );
criteria = session.createCriteria( Person.class );
projList = Projections.projectionList();
projList.add( Projections.property( "name" ) );
criteria.setProjection( projList );
criteria.add( Restrictions.fkIsNotNull( "city" ) );
final List<String> nonNullResults = criteria.list();
assertThat( nonNullResults.size(), is( 1 ) );
assertThat( nonNullResults.get( 0 ), is( "John Doe" ) );
// selecting Person -> city Foreign key
criteria = session.createCriteria( Person.class );
projList = Projections.projectionList();
projList.add( Projections.fk( "city" ) );
criteria.setProjection( projList );
criteria.add( Restrictions.fkIsNotNull( "city" ) );
final List<Integer> foreigKeyResults = criteria.list();
assertThat( foreigKeyResults.size(), is( 1 ) );
assertThat( foreigKeyResults.get( 0 ), is( 1 ) );
//end::associations-not-found-fk-criteria-example[]
// In addition, make sure that the two executed queries do not create a join
assertThat( sqlStatementInterceptor.getSqlQueries().size(), is( 3 ) );
assertFalse( sqlStatementInterceptor.getSqlQueries().get( 0 ).contains( " join " ) );
assertFalse( sqlStatementInterceptor.getSqlQueries().get( 1 ).contains( " join " ) );
assertFalse( sqlStatementInterceptor.getSqlQueries().get( 2 ).contains( " join " ) );
} );
}
//tag::associations-not-found-domain-model-example[]
@Entity
@Table( name = "Person" )
@Entity(name = "Person")
@Table(name = "Person")
public static class Person {
@Id
private Long id;
private Integer id;
private String name;
private String cityName;
@ManyToOne( fetch = FetchType.LAZY )
@NotFound ( action = NotFoundAction.IGNORE )
@JoinColumn(
name = "cityName",
referencedColumnName = "name",
insertable = false,
updatable = false
)
@ManyToOne
@NotFound(action = NotFoundAction.IGNORE)
@JoinColumn(name = "city_fk", referencedColumnName = "id", foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT))
private City city;
//Getters and setters are omitted for brevity
//end::associations-not-found-domain-model-example[]
//end::associations-not-found-domain-model-example[]
public Long getId() {
public Person() {
}
public Person(Integer id, String name, City city) {
this.id = id;
this.name = name;
this.city = city;
}
public Integer getId() {
return id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
@ -112,40 +241,39 @@ public class NotFoundTest extends BaseEntityManagerFunctionalTestCase {
this.name = name;
}
public String getCityName() {
return cityName;
}
public void setCityName(String cityName) {
this.cityName = cityName;
this.city = null;
}
public City getCity() {
return city;
}
//tag::associations-not-found-domain-model-example[]
//tag::associations-not-found-domain-model-example[]
}
@Entity
@Table( name = "City" )
@Entity(name = "City")
@Table(name = "City")
public static class City implements Serializable {
@Id
@GeneratedValue
private Long id;
private Integer id;
private String name;
//Getters and setters are omitted for brevity
//end::associations-not-found-domain-model-example[]
//end::associations-not-found-domain-model-example[]
public Long getId() {
public City() {
}
public City(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public void setId(Long id) {
public void setId(Integer id) {
this.id = id;
}
@ -156,7 +284,7 @@ public class NotFoundTest extends BaseEntityManagerFunctionalTestCase {
public void setName(String name) {
this.name = name;
}
//tag::associations-not-found-domain-model-example[]
//tag::associations-not-found-domain-model-example[]
}
//end::associations-not-found-domain-model-example[]
}
}