HHH-15425 Improved association @NotFound documentation
This commit is contained in:
parent
29607ce760
commit
57c1a029d4
|
@ -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]
|
||||
----
|
||||
====
|
||||
|
|
|
@ -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[]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue