Skip to content

Commit 522269e

Browse files
jrenaatgavinking
authored andcommitted
HHH-1661 throw when merge() applied to a definitely-removed instance
group effort by @jrenaat, @beikov, and myself Signed-off-by: Jan Schatteman <jschatte@redhat.com> Signed-off-by: Gavin King <gavin@hibernate.org>
1 parent cbcd266 commit 522269e

File tree

9 files changed

+541
-23
lines changed

9 files changed

+541
-23
lines changed

hibernate-core/src/main/java/org/hibernate/boot/model/internal/PropertyBinder.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -983,8 +983,6 @@ private static void bindVersionProperty(
983983
rootClass.setDeclaredVersion( property );
984984
}
985985

986-
final SimpleValue simpleValue = (SimpleValue) property.getValue();
987-
simpleValue.setNullValue( "undefined" );
988986
rootClass.setOptimisticLockStyle( OptimisticLockStyle.VERSION );
989987
if ( LOG.isTraceEnabled() ) {
990988
final SimpleValue versionValue = (SimpleValue) rootClass.getVersion().getValue();

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
*/
77
package org.hibernate.engine.internal;
88

9-
import java.lang.reflect.Constructor;
109
import java.util.function.Supplier;
1110

12-
import org.hibernate.InstantiationException;
1311
import org.hibernate.MappingException;
12+
import org.hibernate.boot.spi.MetadataBuildingContext;
13+
import org.hibernate.engine.jdbc.spi.JdbcServices;
1414
import org.hibernate.engine.spi.IdentifierValue;
15+
import org.hibernate.engine.spi.SharedSessionDelegatorBaseImpl;
1516
import org.hibernate.engine.spi.VersionValue;
1617
import org.hibernate.mapping.KeyValue;
1718
import org.hibernate.property.access.spi.Getter;
@@ -94,7 +95,8 @@ public static <T> VersionValue getUnsavedVersionValue(
9495

9596
// if the version of a newly instantiated object is not the same
9697
// as the version seed value, use that as the unsaved-value
97-
final T seedValue = jtd.seed( length, precision, scale, null );
98+
final T seedValue = jtd.seed( length, precision, scale,
99+
mockSession( bootVersionMapping.getBuildingContext() ) );
98100
return jtd.areEqual( seedValue, defaultValue )
99101
? VersionValue.UNDEFINED
100102
: new VersionValue( defaultValue );
@@ -119,6 +121,16 @@ public static <T> VersionValue getUnsavedVersionValue(
119121

120122
}
121123

124+
private static SharedSessionDelegatorBaseImpl mockSession(MetadataBuildingContext context) {
125+
return new SharedSessionDelegatorBaseImpl(null) {
126+
@Override
127+
public JdbcServices getJdbcServices() {
128+
return context.getBootstrapContext().getServiceRegistry()
129+
.requireService( JdbcServices.class );
130+
}
131+
};
132+
}
133+
122134
private UnsavedValueFactory() {
123135
}
124136
}

hibernate-core/src/main/java/org/hibernate/event/internal/DefaultMergeEventListener.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -405,17 +405,26 @@ protected void entityIsDetached(MergeEvent event, Object copiedId, Object origin
405405
final Object result = source.getLoadQueryInfluencers().fromInternalFetchProfile(
406406
CascadingFetchProfile.MERGE,
407407
() -> source.get( entityName, clonedIdentifier )
408-
409408
);
410-
if ( result == null ) {
411-
//TODO: we should throw an exception if we really *know* for sure
412-
// that this is a detached instance, rather than just assuming
413-
//throw new StaleObjectStateException(entityName, id);
414409

410+
if ( result == null ) {
411+
LOG.trace( "Detached instance not found in database" );
415412
// we got here because we assumed that an instance
416-
// with an assigned id was detached, when it was
417-
// really persistent
418-
entityIsTransient( event, clonedIdentifier, copyCache );
413+
// with an assigned id and no version was detached,
414+
// when it was really transient (or deleted)
415+
final Boolean knownTransient = persister.isTransient( entity, source );
416+
if ( knownTransient == Boolean.FALSE ) {
417+
// we know for sure it's detached (generated id
418+
// or a version property), and so the instance
419+
// must have been deleted by another transaction
420+
throw new StaleObjectStateException( entityName, id );
421+
}
422+
else {
423+
// we know for sure it's transient, or we just
424+
// don't have information (assigned id and no
425+
// version property) so keep assuming transient
426+
entityIsTransient( event, clonedIdentifier, copyCache );
427+
}
419428
}
420429
else {
421430
// before cascade!

hibernate-core/src/test/java/org/hibernate/orm/test/bytecode/enhancement/merge/MergeDetachedCascadedCollectionInEmbeddableTest.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import jakarta.persistence.JoinColumn;
1818
import jakarta.persistence.OneToMany;
1919

20+
import jakarta.persistence.OptimisticLockException;
2021
import org.hibernate.testing.bytecode.enhancement.extension.BytecodeEnhanced;
2122
import org.hibernate.testing.orm.junit.DomainModel;
2223
import org.hibernate.testing.orm.junit.JiraKey;
@@ -27,6 +28,7 @@
2728
import static org.hibernate.orm.test.bytecode.enhancement.merge.MergeDetachedCascadedCollectionInEmbeddableTest.Heading;
2829
import static org.hibernate.orm.test.bytecode.enhancement.merge.MergeDetachedCascadedCollectionInEmbeddableTest.Thing;
2930
import static org.junit.Assert.assertNotSame;
31+
import static org.junit.Assert.fail;
3032

3133
import org.junit.jupiter.api.Test;
3234

@@ -55,13 +57,21 @@ public void testMergeDetached(SessionFactoryScope scope) {
5557
return entity;
5658
} );
5759

58-
scope.inTransaction( session -> {
59-
heading.name = "updated";
60-
Heading headingMerged = (Heading) session.merge( heading );
61-
assertNotSame( heading, headingMerged );
62-
assertNotSame( heading.grouping, headingMerged.grouping );
63-
assertNotSame( heading.grouping.things, headingMerged.grouping.things );
64-
} );
60+
try {
61+
scope.inTransaction(session -> {
62+
heading.name = "updated";
63+
Heading headingMerged = session.merge(heading);
64+
assertNotSame(heading, headingMerged);
65+
assertNotSame(heading.grouping, headingMerged.grouping);
66+
assertNotSame(heading.grouping.things, headingMerged.grouping.things);
67+
fail();
68+
});
69+
}
70+
catch (OptimisticLockException e) {
71+
// expected since tx above was never committed
72+
// so the entity had id generated but was never
73+
// actually inserted in database
74+
}
6575
}
6676

6777
@Entity(name = "Heading")
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*
2+
* Hibernate, Relational Persistence for Idiomatic Java
3+
*
4+
* License: GNU Lesser General Public License (LGPL), version 2.1 or later
5+
* See the lgpl.txt file in the root directory or http://www.gnu.org/licenses/lgpl-2.1.html
6+
*/
7+
package org.hibernate.orm.test.exceptionhandling;
8+
9+
import java.sql.Timestamp;
10+
11+
import org.hibernate.dialect.H2Dialect;
12+
13+
import org.hibernate.testing.TestForIssue;
14+
import org.hibernate.testing.orm.junit.DomainModel;
15+
import org.hibernate.testing.orm.junit.RequiresDialect;
16+
import org.hibernate.testing.orm.junit.SessionFactory;
17+
import org.hibernate.testing.orm.junit.SessionFactoryScope;
18+
import org.junit.jupiter.api.Test;
19+
20+
import jakarta.persistence.Column;
21+
import jakarta.persistence.Entity;
22+
import jakarta.persistence.GeneratedValue;
23+
import jakarta.persistence.Id;
24+
import jakarta.persistence.OptimisticLockException;
25+
import jakarta.persistence.Version;
26+
27+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
28+
import static org.junit.jupiter.api.Assertions.assertThrows;
29+
30+
/**
31+
* @author Jan Schatteman
32+
*/
33+
@TestForIssue( jiraKey = "HHH-1661")
34+
@RequiresDialect( H2Dialect.class )
35+
public class StaleObjectMergeTest {
36+
37+
@DomainModel(
38+
annotatedClasses = { A.class }
39+
)
40+
@SessionFactory
41+
@Test
42+
public void testStaleNonVersionEntityMerged(SessionFactoryScope scope) {
43+
A a = new A();
44+
scope.inTransaction(
45+
session -> session.persist( a )
46+
);
47+
48+
scope.inTransaction(
49+
session -> {
50+
A aGet = session.get( A.class, a.getId() );
51+
session.remove( aGet );
52+
}
53+
);
54+
55+
assertThrows(
56+
OptimisticLockException.class,
57+
() -> scope.inTransaction(
58+
session -> session.merge( a )
59+
)
60+
);
61+
}
62+
63+
@DomainModel(
64+
annotatedClasses = { B.class, C.class }
65+
)
66+
@SessionFactory
67+
@Test
68+
public void testStalePrimitiveAndWrapperVersionEntityMerged(SessionFactoryScope scope) {
69+
B b = new B();
70+
// this is a workaround because the version is seeded to 0, so there's no way of differentiating
71+
// a new instance from a detached one for primitive types
72+
b.setVersion( 1 );
73+
scope.inTransaction(
74+
session -> session.persist( b )
75+
);
76+
77+
scope.inTransaction(
78+
session -> {
79+
B bGet = session.get( B.class, b.getId() );
80+
session.remove( bGet );
81+
}
82+
);
83+
84+
assertThrows(
85+
OptimisticLockException.class,
86+
() -> {
87+
scope.inTransaction(
88+
session -> {
89+
session.merge( b );
90+
}
91+
);
92+
}
93+
);
94+
95+
C c = new C();
96+
scope.inTransaction(
97+
session -> session.persist( c )
98+
);
99+
100+
scope.inTransaction(
101+
session -> {
102+
C cGet = session.get( C.class, c.getId() );
103+
session.remove( cGet );
104+
}
105+
);
106+
107+
assertThrows(
108+
OptimisticLockException.class,
109+
() -> {
110+
scope.inTransaction(
111+
session -> {
112+
session.merge( c );
113+
}
114+
);
115+
}
116+
);
117+
}
118+
119+
@DomainModel(
120+
annotatedClasses = { D.class }
121+
)
122+
@SessionFactory
123+
@Test
124+
public void testStaleTimestampVersionEntityMerged(SessionFactoryScope scope) {
125+
D d = new D();
126+
scope.inTransaction(
127+
session -> session.persist( d )
128+
);
129+
130+
scope.inTransaction(
131+
session -> {
132+
D dGet = session.get( D.class, d.getId() );
133+
session.remove( dGet );
134+
}
135+
);
136+
137+
assertThrows(
138+
OptimisticLockException.class,
139+
() -> {
140+
scope.inTransaction(
141+
session -> {
142+
session.merge( d );
143+
}
144+
);
145+
}
146+
);
147+
}
148+
149+
@Entity(name = "A")
150+
public static class A {
151+
@Id
152+
@GeneratedValue
153+
private long id;
154+
155+
public Long getId() {
156+
return id;
157+
}
158+
159+
public void setId(Long id) {
160+
this.id = id;
161+
}
162+
}
163+
164+
@Entity(name = "B")
165+
public static class B {
166+
@Id
167+
@GeneratedValue
168+
private long id;
169+
170+
@Version
171+
@Column(name = "ver")
172+
private int version;
173+
174+
public Long getId() {
175+
return id;
176+
}
177+
178+
public void setId(Long id) {
179+
this.id = id;
180+
}
181+
182+
public int getVersion() {
183+
return version;
184+
}
185+
186+
public void setVersion(int version) {
187+
this.version = version;
188+
}
189+
}
190+
191+
@Entity(name = "C")
192+
public static class C {
193+
@Id
194+
@GeneratedValue
195+
private long id;
196+
197+
@Version
198+
@Column(name = "ver")
199+
private Integer version;
200+
201+
public Long getId() {
202+
return id;
203+
}
204+
205+
public void setId(Long id) {
206+
this.id = id;
207+
}
208+
}
209+
210+
@Entity(name = "D")
211+
public static class D {
212+
@Id
213+
@GeneratedValue
214+
private long id;
215+
216+
@Version
217+
@Column(name = "ver")
218+
private Timestamp version;
219+
220+
public Long getId() {
221+
return id;
222+
}
223+
224+
public void setId(Long id) {
225+
this.id = id;
226+
}
227+
}
228+
}

0 commit comments

Comments
 (0)