Skip to content

Commit 27bbdfc

Browse files
mbelladebeikov
authored andcommitted
HHH-18212 Setting to control transient check strictness for unowned associations
1 parent 055570c commit 27bbdfc

File tree

9 files changed

+154
-40
lines changed

9 files changed

+154
-40
lines changed

hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
import static org.hibernate.cfg.AvailableSettings.USE_STRUCTURED_CACHE;
130130
import static org.hibernate.cfg.AvailableSettings.USE_SUBSELECT_FETCH;
131131
import static org.hibernate.cfg.CacheSettings.QUERY_CACHE_LAYOUT;
132+
import static org.hibernate.cfg.PersistenceSettings.UNOWNED_ASSOCIATION_TRANSIENT_CHECK;
132133
import static org.hibernate.cfg.QuerySettings.DEFAULT_NULL_ORDERING;
133134
import static org.hibernate.cfg.QuerySettings.PORTABLE_INTEGER_DIVISION;
134135
import static org.hibernate.engine.config.spi.StandardConverters.BOOLEAN;
@@ -206,6 +207,7 @@ public class SessionFactoryOptionsBuilder implements SessionFactoryOptions {
206207
private boolean orderUpdatesEnabled;
207208
private boolean orderInsertsEnabled;
208209
private boolean collectionsInDefaultFetchGroupEnabled = true;
210+
private boolean UnownedAssociationTransientCheck;
209211

210212
// JPA callbacks
211213
private final boolean callbacksEnabled;
@@ -619,6 +621,12 @@ else if ( jdbcTimeZoneValue != null ) {
619621
configurationSettings,
620622
Statistics.DEFAULT_QUERY_STATISTICS_MAX_SIZE
621623
);
624+
625+
this.UnownedAssociationTransientCheck = getBoolean(
626+
UNOWNED_ASSOCIATION_TRANSIENT_CHECK,
627+
configurationSettings,
628+
isJpaBootstrap()
629+
);
622630
}
623631

624632
private boolean disallowBatchUpdates(Dialect dialect, ExtractedDatabaseMetaData meta) {
@@ -1255,6 +1263,11 @@ public boolean isCollectionsInDefaultFetchGroupEnabled() {
12551263
return collectionsInDefaultFetchGroupEnabled;
12561264
}
12571265

1266+
@Override
1267+
public boolean isUnownedAssociationTransientCheck() {
1268+
return UnownedAssociationTransientCheck;
1269+
}
1270+
12581271
@Override
12591272
public int getPreferredSqlTypeCodeForBoolean() {
12601273
return preferredSqlTypeCodeForBoolean;

hibernate-core/src/main/java/org/hibernate/boot/spi/AbstractDelegatingSessionFactoryOptions.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,11 @@ public boolean isCollectionsInDefaultFetchGroupEnabled() {
448448
return delegate.isCollectionsInDefaultFetchGroupEnabled();
449449
}
450450

451+
@Override
452+
public boolean isUnownedAssociationTransientCheck() {
453+
return delegate.isUnownedAssociationTransientCheck();
454+
}
455+
451456
@Override
452457
public boolean isUseOfJdbcNamedParametersEnabled() {
453458
return delegate().isUseOfJdbcNamedParametersEnabled();

hibernate-core/src/main/java/org/hibernate/boot/spi/SessionFactoryOptions.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ default boolean isCollectionsInDefaultFetchGroupEnabled() {
325325
return false;
326326
}
327327

328+
boolean isUnownedAssociationTransientCheck();
329+
328330
@Incubating
329331
int getPreferredSqlTypeCodeForBoolean();
330332

hibernate-core/src/main/java/org/hibernate/cfg/PersistenceSettings.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,12 @@ public interface PersistenceSettings {
182182
*/
183183
@Deprecated
184184
String JPA_TRANSACTION_TYPE = "javax.persistence.transactionType";
185+
186+
/**
187+
* Specifies whether unowned (i.e. {@code mapped-by}) associations should be considered
188+
* when validating transient entity instance references.
189+
*
190+
* @settingDefault {@code false}
191+
*/
192+
String UNOWNED_ASSOCIATION_TRANSIENT_CHECK = "hibernate.unowned_association_transient_check";
185193
}

hibernate-core/src/main/java/org/hibernate/engine/internal/Cascade.java

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@
2424
import org.hibernate.event.spi.EventSource;
2525
import org.hibernate.internal.CoreLogging;
2626
import org.hibernate.internal.CoreMessageLogger;
27+
import org.hibernate.metamodel.mapping.AttributeMapping;
28+
import org.hibernate.metamodel.mapping.AttributeMappingsList;
29+
import org.hibernate.metamodel.mapping.PluralAttributeMapping;
2730
import org.hibernate.persister.collection.CollectionPersister;
2831
import org.hibernate.persister.entity.EntityPersister;
2932
import org.hibernate.type.AssociationType;
3033
import org.hibernate.type.CollectionType;
3134
import org.hibernate.type.ComponentType;
3235
import org.hibernate.type.CompositeType;
3336
import org.hibernate.type.EntityType;
37+
import org.hibernate.type.ManyToOneType;
38+
import org.hibernate.type.OneToOneType;
3439
import org.hibernate.type.Type;
3540

3641
import static org.hibernate.engine.internal.ManagedTypeHelper.isHibernateProxy;
@@ -167,6 +172,7 @@ else if ( action.performOnLazyProperty() && type.isEntityType() ) {
167172
type,
168173
style,
169174
propertyName,
175+
persister.getAttributeMapping( i ),
170176
anything,
171177
false
172178
);
@@ -208,13 +214,17 @@ private static <T> void cascadeProperty(
208214
final Type type,
209215
final CascadeStyle style,
210216
final String propertyName,
217+
final AttributeMapping attributeMapping,
211218
final T anything,
212219
final boolean isCascadeDeleteEnabled) throws HibernateException {
213-
220+
214221
if ( child != null ) {
215222
if ( type.isAssociationType() ) {
216223
final AssociationType associationType = (AssociationType) type;
217-
if ( cascadeAssociationNow( cascadePoint, associationType ) ) {
224+
final boolean strictUnowned = eventSource.getSessionFactory()
225+
.getSessionFactoryOptions()
226+
.isUnownedAssociationTransientCheck();
227+
if ( cascadeAssociationNow( action, cascadePoint, associationType, attributeMapping, strictUnowned ) ) {
218228
cascadeAssociation(
219229
action,
220230
cascadePoint,
@@ -244,6 +254,7 @@ else if ( type.isComponentType() ) {
244254
parent,
245255
child,
246256
(CompositeType) type,
257+
attributeMapping,
247258
anything
248259
);
249260
if ( componentPath != null ) {
@@ -375,8 +386,35 @@ private static boolean isLogicalOneToOne(Type type) {
375386
return type.isEntityType() && ( (EntityType) type ).isLogicalOneToOne();
376387
}
377388

378-
private static boolean cascadeAssociationNow(final CascadePoint cascadePoint, AssociationType associationType) {
379-
return associationType.getForeignKeyDirection().cascadeNow( cascadePoint );
389+
private static boolean cascadeAssociationNow(
390+
CascadingAction<?> action,
391+
CascadePoint cascadePoint,
392+
AssociationType associationType,
393+
AttributeMapping attributeMapping,
394+
boolean isStrictUnownedTransienceEnabled) {
395+
return associationType.getForeignKeyDirection().cascadeNow( cascadePoint )
396+
// For check on flush, we should only check owned associations when strictness is enforced
397+
&& ( action != CHECK_ON_FLUSH || isStrictUnownedTransienceEnabled || !isUnownedAssociation( associationType, attributeMapping ) );
398+
}
399+
400+
private static boolean isUnownedAssociation(AssociationType associationType, AttributeMapping attributeMapping) {
401+
if ( associationType.isEntityType() ) {
402+
if ( associationType instanceof ManyToOneType ) {
403+
final ManyToOneType manyToOne = (ManyToOneType) associationType;
404+
// logical one-to-one + non-null unique key property name indicates unowned
405+
return manyToOne.isLogicalOneToOne() && manyToOne.getRHSUniqueKeyPropertyName() != null;
406+
}
407+
else if ( associationType instanceof OneToOneType ) {
408+
final OneToOneType oneToOne = (OneToOneType) associationType;
409+
// constrained false + non-null unique key property name indicates unowned
410+
return oneToOne.isNullable() && oneToOne.getRHSUniqueKeyPropertyName() != null;
411+
}
412+
}
413+
else if ( associationType.isCollectionType() ) {
414+
// for collections, we can ask the persister if we're on the inverse side
415+
return ( (PluralAttributeMapping) attributeMapping ).getCollectionDescriptor().isInverse();
416+
}
417+
return false;
380418
}
381419

382420
private static <T> void cascadeComponent(
@@ -387,11 +425,14 @@ private static <T> void cascadeComponent(
387425
final Object parent,
388426
final Object child,
389427
final CompositeType componentType,
428+
final AttributeMapping attributeMapping,
390429
final T anything) {
391-
392430
Object[] children = null;
393431
final Type[] types = componentType.getSubtypes();
394432
final String[] propertyNames = componentType.getPropertyNames();
433+
final AttributeMappingsList subMappings = attributeMapping != null ?
434+
attributeMapping.asEmbeddedAttributeMapping().getEmbeddableTypeDescriptor().getAttributeMappings() :
435+
null;
395436
for ( int i = 0; i < types.length; i++ ) {
396437
final CascadeStyle componentPropertyStyle = componentType.getCascadeStyle( i );
397438
final String subPropertyName = propertyNames[i];
@@ -411,6 +452,7 @@ private static <T> void cascadeComponent(
411452
types[i],
412453
componentPropertyStyle,
413454
subPropertyName,
455+
subMappings != null && i < subMappings.size() ? subMappings.get( i ) : null,
414456
anything,
415457
false
416458
);
@@ -552,6 +594,7 @@ private static <T> void cascadeCollectionElements(
552594
elemType,
553595
style,
554596
collectionType.getRole().substring( collectionType.getRole().lastIndexOf('.') + 1 ),
597+
null,
555598
anything,
556599
isCascadeDeleteEnabled
557600
);

hibernate-core/src/main/java/org/hibernate/sql/results/graph/entity/internal/EntityInitializerImpl.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
import static org.hibernate.engine.internal.ManagedTypeHelper.asPersistentAttributeInterceptable;
8080
import static org.hibernate.engine.internal.ManagedTypeHelper.isPersistentAttributeInterceptable;
8181
import static org.hibernate.internal.log.LoggingHelper.toLoggableString;
82+
import static org.hibernate.metamodel.mapping.ForeignKeyDescriptor.Nature.TARGET;
8283
import static org.hibernate.proxy.HibernateProxy.extractLazyInitializer;
8384

8485
/**
@@ -664,6 +665,14 @@ public void resolveInstance(Object instance, EntityInitializerData data) {
664665
data.concreteDescriptor.getIdentifier( data.getInstance(), session )
665666
);
666667
data.entityHolder = session.getPersistenceContextInternal().getEntityHolder( data.entityKey );
668+
if ( data.entityHolder == null ) {
669+
// Entity was most probably removed in the same session without setting the reference to null
670+
resolveKey( data );
671+
assert data.getState() == State.MISSING;
672+
assert referencedModelPart instanceof ToOneAttributeMapping
673+
&& ( (ToOneAttributeMapping) referencedModelPart ).getSideNature() == TARGET;
674+
return;
675+
}
667676
// If the entity initializer is null, we know the entity is fully initialized,
668677
// otherwise it will be initialized by some other initializer
669678
data.setState( data.entityHolder.getEntityInitializer() == null ? State.INITIALIZED : State.RESOLVED );

hibernate-core/src/test/java/org/hibernate/orm/test/annotations/manytoone/ManyToOneJoinTest.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,7 @@ public class ManyToOneJoinTest {
3636
@AfterEach
3737
public void teardDown(SessionFactoryScope scope) {
3838
scope.inTransaction(
39-
session -> {
40-
session.createMutationQuery( "delete from TreeType" ).executeUpdate();
41-
session.createMutationQuery( "delete from ForestType" ).executeUpdate();
42-
session.createMutationQuery( "delete from BiggestForest" ).executeUpdate();
43-
}
39+
session -> session.getSessionFactory().getSchemaManager().truncateMappedObjects()
4440
);
4541
}
4642

hibernate-core/src/test/java/org/hibernate/orm/test/bytecode/enhancement/lazy/LazyOneToOneRemoveFlushAccessTest.java

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
package org.hibernate.orm.test.bytecode.enhancement.lazy;
88

99
import org.hibernate.TransientObjectException;
10+
import org.hibernate.cfg.AvailableSettings;
11+
import org.hibernate.engine.spi.SessionImplementor;
1012

1113
import org.hibernate.testing.bytecode.enhancement.extension.BytecodeEnhanced;
1214
import org.hibernate.testing.orm.junit.DomainModel;
1315
import org.hibernate.testing.orm.junit.Jira;
16+
import org.hibernate.testing.orm.junit.ServiceRegistry;
1417
import org.hibernate.testing.orm.junit.SessionFactory;
1518
import org.hibernate.testing.orm.junit.SessionFactoryScope;
16-
import org.junit.jupiter.api.AfterAll;
17-
import org.junit.jupiter.api.BeforeAll;
19+
import org.hibernate.testing.orm.junit.Setting;
1820
import org.junit.jupiter.api.Test;
1921

2022
import jakarta.persistence.Entity;
@@ -30,49 +32,78 @@
3032
LazyOneToOneRemoveFlushAccessTest.ContainingEntity.class,
3133
LazyOneToOneRemoveFlushAccessTest.ContainedEntity.class
3234
} )
33-
@SessionFactory
3435
@BytecodeEnhanced( runNotEnhancedAsWell = true )
3536
@Jira( "https://hibernate.atlassian.net/browse/HHH-18212" )
3637
public class LazyOneToOneRemoveFlushAccessTest {
3738
@Test
38-
public void test(SessionFactoryScope scope) {
39+
@SessionFactory
40+
@ServiceRegistry( settings = @Setting( name = AvailableSettings.UNOWNED_ASSOCIATION_TRANSIENT_CHECK, value = "true" ) )
41+
public void testStrict(SessionFactoryScope scope) {
42+
scope.inTransaction( session -> executeTest( session, true ) );
43+
}
44+
45+
@Test
46+
@SessionFactory
47+
@ServiceRegistry( settings = @Setting( name = AvailableSettings.UNOWNED_ASSOCIATION_TRANSIENT_CHECK, value = "false" ) )
48+
public void testNonStrict(SessionFactoryScope scope) {
49+
scope.inTransaction( session -> executeTest( session, false ) );
50+
}
51+
52+
private void executeTest(SessionImplementor session, boolean shouldThrow) {
53+
setUp( session );
54+
session.flush();
55+
session.clear();
56+
3957
try {
40-
scope.inTransaction( session -> {
41-
final ContainingEntity containing = session.find( ContainingEntity.class, 2 );
42-
final ContainedEntity containedEntity = containing.getContained();
43-
session.remove( containedEntity );
44-
session.flush();
45-
} );
46-
fail( "Not clearing containing.getContained() should trigger a transient object exception" );
58+
final ContainingEntity containing = session.find( ContainingEntity.class, 2 );
59+
final ContainedEntity containedEntity = containing.getContained();
60+
session.remove( containedEntity );
61+
session.flush();
62+
63+
if ( shouldThrow ) {
64+
fail( "Not clearing containing.getContained() should trigger a transient object exception" );
65+
}
66+
else {
67+
final ContainingEntity parent = containing.getParent();
68+
// Lazy loading will load the child based on parent, which triggers the NPE of HHH-18212
69+
final ContainingEntity child = parent.getChild();
70+
assertThat( child.getId() ).isEqualTo( 2 );
71+
assertThat( child ).isSameAs( containing );
72+
// child.getContained() is not null here as the state for ContainingEntity#2 is not refreshed
73+
// assertThat( child.getContained() ).isNull();
74+
assertThat( session.contains( child.getContained() ) ).isFalse();
75+
}
4776
}
4877
catch (Exception e) {
49-
assertThat( e.getCause() ).isInstanceOf( TransientObjectException.class )
50-
.hasMessageContaining( "persistent instance references an unsaved transient instance" );
78+
if ( shouldThrow ) {
79+
assertThat( e.getCause() ).isInstanceOf( TransientObjectException.class )
80+
.hasMessageContaining( "persistent instance references an unsaved transient instance" );
81+
}
82+
else {
83+
fail( "Test should work with transient strictness disabled, instead threw", e );
84+
}
5185
}
5286
}
5387

54-
@BeforeAll
55-
public void setUp(SessionFactoryScope scope) {
56-
scope.inTransaction( session -> {
57-
final ContainingEntity entity1 = new ContainingEntity();
58-
entity1.setId( 1 );
88+
public void setUp(SessionImplementor session) {
89+
final ContainingEntity entity1 = new ContainingEntity();
90+
entity1.setId( 1 );
5991

60-
final ContainingEntity containingEntity1 = new ContainingEntity();
61-
containingEntity1.setId( 2 );
92+
final ContainingEntity containingEntity1 = new ContainingEntity();
93+
containingEntity1.setId( 2 );
6294

63-
entity1.setChild( containingEntity1 );
64-
containingEntity1.setParent( entity1 );
95+
entity1.setChild( containingEntity1 );
96+
containingEntity1.setParent( entity1 );
6597

66-
final ContainedEntity containedEntity = new ContainedEntity();
67-
containedEntity.setId( 3 );
98+
final ContainedEntity containedEntity = new ContainedEntity();
99+
containedEntity.setId( 3 );
68100

69-
session.persist( containingEntity1 );
70-
session.persist( entity1 );
71-
session.persist( containedEntity );
101+
session.persist( containingEntity1 );
102+
session.persist( entity1 );
103+
session.persist( containedEntity );
72104

73-
containingEntity1.setContained( containedEntity );
74-
containedEntity.setContaining( containingEntity1 );
75-
} );
105+
containingEntity1.setContained( containedEntity );
106+
containedEntity.setContaining( containingEntity1 );
76107
}
77108

78109
@Entity( name = "ContainingEntity" )

hibernate-core/src/test/java/org/hibernate/orm/test/jpa/cascade2/CascadeTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import org.hibernate.Session;
1010

1111
import org.hibernate.TransientObjectException;
12+
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
13+
import org.hibernate.cfg.AvailableSettings;
1214
import org.hibernate.orm.test.jpa.model.AbstractJPATest;
1315
import org.junit.jupiter.api.Test;
1416

@@ -38,6 +40,11 @@ protected String[] getOrmXmlFiles() {
3840
return new String[] { "org/hibernate/orm/test/jpa/cascade2/ParentChild.hbm.xml" };
3941
}
4042

43+
@Override
44+
protected void applySettings(StandardServiceRegistryBuilder builder) {
45+
builder.applySetting( AvailableSettings.UNOWNED_ASSOCIATION_TRANSIENT_CHECK, "true" );
46+
}
47+
4148
@Test
4249
public void testManyToOneGeneratedIdsOnSave() {
4350
// NOTES: Child defines a many-to-one back to its Parent. This

0 commit comments

Comments
 (0)